From c58da381c771774fad854eda5b81900a50e6477e Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:25:44 +0800 Subject: [PATCH] feat(plugins): sync home plugin manifests --- cmd/server/main.go | 8 + internal/homeplugins/sync.go | 183 ++++++++++++ internal/homeplugins/sync_test.go | 231 +++++++++++++++ internal/pluginhost/auth_provider.go | 72 +++++ internal/pluginhost/auth_provider_test.go | 47 +++ internal/pluginstore/github.go | 27 ++ internal/pluginstore/install.go | 159 +++++++++- internal/pluginstore/install_test.go | 92 +++++- internal/pluginstore/registry.go | 9 +- .../executor/helps/home_refresh_test.go | 3 + sdk/cliproxy/auth/types.go | 6 +- sdk/cliproxy/home_plugins.go | 15 + sdk/cliproxy/service.go | 20 +- sdk/pluginhost/host.go | 278 ++++++++++++++++++ sdk/pluginstore/pluginstore.go | 172 +++++++++++ sdk/pluginstore/pluginstore_test.go | 67 +++++ 16 files changed, 1371 insertions(+), 18 deletions(-) create mode 100644 internal/homeplugins/sync.go create mode 100644 internal/homeplugins/sync_test.go create mode 100644 sdk/cliproxy/home_plugins.go create mode 100644 sdk/pluginhost/host.go create mode 100644 sdk/pluginstore/pluginstore.go create mode 100644 sdk/pluginstore/pluginstore_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index dde0678c..d08dab73 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -22,6 +22,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/cmd" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" + "github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" @@ -299,6 +300,13 @@ func main() { parsed.Home = homeCfg parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config parsed.UsageStatisticsEnabled = true + ctxHomePlugins, cancelHomePlugins := context.WithTimeout(context.Background(), 30*time.Second) + errHomePlugins := homeplugins.Sync(ctxHomePlugins, parsed, pluginHost) + cancelHomePlugins() + if errHomePlugins != nil { + log.Errorf("failed to fetch plugins from home: %v", errHomePlugins) + return + } cfg = parsed // Keep a non-empty config path for downstream components (log paths, management assets, etc), diff --git a/internal/homeplugins/sync.go b/internal/homeplugins/sync.go new file mode 100644 index 00000000..708045d3 --- /dev/null +++ b/internal/homeplugins/sync.go @@ -0,0 +1,183 @@ +package homeplugins + +import ( + "context" + "fmt" + "net/http" + "runtime" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore" + "golang.org/x/sys/cpu" + "gopkg.in/yaml.v3" +) + +type Platform struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Variant string `json:"variant,omitempty"` +} + +type PluginRuntime interface { + PluginBusy(id string) bool + UnloadPlugin(id string) bool +} + +// CurrentPlatform reports the platform used by pluginhost discovery. +func CurrentPlatform() Platform { + return Platform{ + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + Variant: cpuVariant(), + } +} + +func NormalizePlatform(platform Platform) Platform { + goos := strings.ToLower(strings.TrimSpace(platform.GOOS)) + switch goos { + case "mac", "macos", "osx": + goos = "darwin" + } + goarch := strings.ToLower(strings.TrimSpace(platform.GOARCH)) + switch goarch { + case "x64", "x86_64": + goarch = "amd64" + case "aarch64": + goarch = "arm64" + } + variant := strings.ToLower(strings.TrimSpace(platform.Variant)) + return Platform{GOOS: goos, GOARCH: goarch, Variant: variant} +} + +func Sync(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime) error { + return SyncPlatform(ctx, cfg, pluginRuntime, CurrentPlatform()) +} + +func SyncPlatform(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime, platform Platform) error { + if cfg == nil || !cfg.Home.Enabled || !cfg.Plugins.Enabled { + return nil + } + platform = NormalizePlatform(platform) + if platform.GOOS == "" { + return fmt.Errorf("home plugins: goos is required") + } + if platform.GOARCH == "" { + return fmt.Errorf("home plugins: goarch is required") + } + root := strings.TrimSpace(cfg.Plugins.Dir) + if root == "" { + root = "plugins" + } + client := newPluginStoreClient(cfg) + for id, item := range cfg.Plugins.Configs { + if !pluginConfigEnabled(item) { + continue + } + manifest, okManifest, errManifest := storeManifestFromPluginConfig(id, item) + if errManifest != nil { + return errManifest + } + if !okManifest { + continue + } + if errSync := installManifest(ctx, client, manifest, root, platform, pluginRuntime); errSync != nil { + return errSync + } + } + return nil +} + +func installManifest(ctx context.Context, client sdkpluginstore.Client, manifest sdkpluginstore.Manifest, root string, platform Platform, pluginRuntime PluginRuntime) error { + id := strings.TrimSpace(manifest.ID) + if id == "" { + return fmt.Errorf("home plugins: manifest plugin id is empty") + } + pluginIsBusy := func() bool { + return pluginRuntime != nil && pluginRuntime.PluginBusy(id) + } + _, errInstall := client.InstallManifest(ctx, manifest, sdkpluginstore.InstallOptions{ + PluginsDir: root, + GOOS: platform.GOOS, + GOARCH: platform.GOARCH, + PluginLoaded: pluginIsBusy, + BeforeWrite: func() error { + if !pluginIsBusy() { + return nil + } + if pluginRuntime == nil || !pluginRuntime.UnloadPlugin(id) && pluginIsBusy() { + return sdkpluginstore.ErrLoadedPluginLocked + } + return nil + }, + }) + if errInstall != nil { + return fmt.Errorf("home plugins: install %s: %w", id, errInstall) + } + return nil +} + +func storeManifestFromPluginConfig(id string, item config.PluginInstanceConfig) (sdkpluginstore.Manifest, bool, error) { + if item.Raw.Kind == 0 { + return sdkpluginstore.Manifest{}, false, nil + } + storeNode := yamlMappingValue(&item.Raw, "store") + if storeNode == nil || storeNode.Kind == 0 { + return sdkpluginstore.Manifest{}, false, nil + } + var manifest sdkpluginstore.Manifest + if errDecode := storeNode.Decode(&manifest); errDecode != nil { + return sdkpluginstore.Manifest{}, false, fmt.Errorf("home plugins: decode store manifest for %s: %w", id, errDecode) + } + if strings.TrimSpace(manifest.ID) == "" { + manifest.ID = strings.TrimSpace(id) + } + if errValidate := manifest.Validate(); errValidate != nil { + return sdkpluginstore.Manifest{}, false, fmt.Errorf("home plugins: invalid store manifest for %s: %w", id, errValidate) + } + return manifest, true, nil +} + +func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + if keyNode == nil || keyNode.Value != key { + continue + } + return node.Content[i+1] + } + return nil +} + +var newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client { + client := &http.Client{} + if cfg != nil && strings.TrimSpace(cfg.ProxyURL) != "" { + util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(cfg.ProxyURL)}, client) + } + return sdkpluginstore.NewClient(client, "") +} + +func pluginConfigEnabled(item config.PluginInstanceConfig) bool { + return item.Enabled != nil && *item.Enabled +} + +func cpuVariant() string { + if runtime.GOARCH != "amd64" { + return "" + } + if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL { + return "v4" + } + if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA { + return "v3" + } + if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT { + return "v2" + } + return "v1" +} diff --git a/internal/homeplugins/sync_test.go b/internal/homeplugins/sync_test.go new file mode 100644 index 00000000..1f1182c9 --- /dev/null +++ b/internal/homeplugins/sync_test.go @@ -0,0 +1,231 @@ +package homeplugins + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore" + "gopkg.in/yaml.v3" +) + +type fakePluginRuntime struct { + busy bool + unloaded []string +} + +func (r *fakePluginRuntime) PluginBusy(id string) bool { + return r.busy +} + +func (r *fakePluginRuntime) UnloadPlugin(id string) bool { + r.unloaded = append(r.unloaded, id) + r.busy = false + return true +} + +func TestSyncPlatformInstallsManifestArtifact(t *testing.T) { + root := t.TempDir() + archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"}) + archiveName := "sample_0.2.0_windows_amd64.zip" + checksum := sha256.Sum256(archiveData) + httpClient := mapHTTPDoer{ + "https://api.github.com/repos/owner/sample-plugin/releases/tags/v0.2.0": []byte(`{ + "tag_name": "v0.2.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"), + } + restore := replacePluginStoreClientForTest(httpClient) + defer restore() + + if errSync := SyncPlatform(context.Background(), syncTestConfig(t, root), nil, Platform{GOOS: "windows", GOARCH: "amd64"}); errSync != nil { + t.Fatalf("SyncPlatform() error = %v", errSync) + } + target := filepath.Join(root, "windows", "amd64", "sample.dll") + got, errRead := os.ReadFile(target) + if errRead != nil { + t.Fatalf("read target: %v", errRead) + } + if string(got) != "library-data" { + t.Fatalf("target data = %q, want library-data", string(got)) + } +} + +func TestSyncPlatformSkipsIdenticalBusyPlugin(t *testing.T) { + root := t.TempDir() + targetDir := filepath.Join(root, "windows", "amd64") + if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { + t.Fatalf("MkdirAll() error = %v", errMkdir) + } + target := filepath.Join(targetDir, "sample.dll") + if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil { + t.Fatalf("WriteFile() error = %v", errWrite) + } + archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"}) + archiveName := "sample_0.2.0_windows_amd64.zip" + checksum := sha256.Sum256(archiveData) + httpClient := mapHTTPDoer{ + "https://api.github.com/repos/owner/sample-plugin/releases/tags/v0.2.0": []byte(`{ + "tag_name": "v0.2.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"), + } + restore := replacePluginStoreClientForTest(httpClient) + defer restore() + + runtime := &fakePluginRuntime{busy: true} + if errSync := SyncPlatform(context.Background(), syncTestConfig(t, root), runtime, Platform{GOOS: "windows", GOARCH: "amd64"}); errSync != nil { + t.Fatalf("SyncPlatform() error = %v", errSync) + } + if len(runtime.unloaded) != 0 { + t.Fatalf("UnloadPlugin() calls = %v, want none", runtime.unloaded) + } + got, errRead := os.ReadFile(target) + if errRead != nil { + t.Fatalf("read target: %v", errRead) + } + if string(got) != "library-data" { + t.Fatalf("target data = %q, want library-data", string(got)) + } +} + +func TestSyncPlatformSkipsConfigWithoutManifest(t *testing.T) { + restore := replacePluginStoreClientForTest(mapHTTPDoer{}) + defer restore() + + cfg := &config.Config{ + Home: config.HomeConfig{Enabled: true}, + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + Configs: map[string]config.PluginInstanceConfig{ + "sample": pluginConfigFromYAML(t, `enabled: true`), + }, + }, + } + if errSync := SyncPlatform(context.Background(), cfg, nil, Platform{GOOS: "linux", GOARCH: "amd64"}); errSync != nil { + t.Fatalf("SyncPlatform() error = %v", errSync) + } +} + +func TestSyncPlatformRejectsInvalidManifest(t *testing.T) { + cfg := &config.Config{ + Home: config.HomeConfig{Enabled: true}, + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + Configs: map[string]config.PluginInstanceConfig{ + "sample": pluginConfigFromYAML(t, ` +enabled: true +store: + id: sample +`), + }, + }, + } + if errSync := SyncPlatform(context.Background(), cfg, nil, Platform{GOOS: "linux", GOARCH: "amd64"}); errSync == nil { + t.Fatal("SyncPlatform() error = nil, want invalid manifest") + } +} + +func syncTestConfig(t *testing.T, root string) *config.Config { + t.Helper() + return &config.Config{ + Home: config.HomeConfig{Enabled: true}, + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: root, + Configs: map[string]config.PluginInstanceConfig{ + "sample": pluginConfigFromYAML(t, ` +enabled: true +store: + id: sample + name: Sample + description: Adds sample support. + author: owner + version: 0.2.0 + release-tag: v0.2.0 + repository: https://github.com/owner/sample-plugin +`), + }, + }, + } +} + +func pluginConfigFromYAML(t *testing.T, text string) config.PluginInstanceConfig { + t.Helper() + var item config.PluginInstanceConfig + if errUnmarshal := yaml.Unmarshal([]byte(text), &item); errUnmarshal != nil { + t.Fatalf("unmarshal plugin config: %v", errUnmarshal) + } + return item +} + +func replacePluginStoreClientForTest(httpClient sdkpluginstore.HTTPDoer) func() { + previous := newPluginStoreClient + newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client { + return sdkpluginstore.NewClient(httpClient, "") + } + return func() { + newPluginStoreClient = previous + } +} + +func makeZip(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for name, content := range files { + file, errCreate := writer.Create(name) + if errCreate != nil { + t.Fatalf("Create(%s) error = %v", name, errCreate) + } + if _, errWrite := file.Write([]byte(content)); errWrite != nil { + t.Fatalf("Write(%s) error = %v", name, errWrite) + } + } + if errClose := writer.Close(); errClose != nil { + t.Fatalf("Close() error = %v", errClose) + } + return buffer.Bytes() +} + +type mapHTTPDoer map[string][]byte + +func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) { + body, ok := c[req.URL.String()] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("not found")), + Header: make(http.Header), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: make(http.Header), + Request: req, + }, nil +} diff --git a/internal/pluginhost/auth_provider.go b/internal/pluginhost/auth_provider.go index 32cf6e24..f559ec8b 100644 --- a/internal/pluginhost/auth_provider.go +++ b/internal/pluginhost/auth_provider.go @@ -328,6 +328,78 @@ func (h *Host) callPollLogin(ctx context.Context, record capabilityRecord, provi return resp, true, nil } +func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (refreshed *coreauth.Auth, handled bool, err error) { + if h == nil || auth == nil { + return nil, false, nil + } + record := h.authProviderRecord(authProvider(auth)) + if record == nil || record.plugin.Capabilities.AuthProvider == nil { + return nil, false, nil + } + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(record.id, "AuthProvider.RefreshAuth", recovered) + refreshed = nil + handled = true + err = fmt.Errorf("auth provider refresh panic: %v", recovered) + } + }() + + pluginResp, errRefresh := record.plugin.Capabilities.AuthProvider.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{ + AuthID: authID(auth), + AuthProvider: authProvider(auth), + StorageJSON: storageJSONFromAuth(auth), + Metadata: cloneAnyMap(authMetadata(auth)), + Attributes: authAttributes(auth), + Host: h.hostConfigSummary(), + HTTPClient: h.newHTTPClient(auth), + }) + if errRefresh != nil { + return nil, true, errRefresh + } + data := pluginResp.Auth + if strings.TrimSpace(data.Provider) == "" { + data.Provider = authProvider(auth) + } + if strings.TrimSpace(data.ID) == "" { + data.ID = authID(auth) + } + if strings.TrimSpace(data.FileName) == "" { + data.FileName = auth.FileName + } + if strings.TrimSpace(data.Label) == "" { + data.Label = auth.Label + } + if strings.TrimSpace(data.Prefix) == "" { + data.Prefix = auth.Prefix + } + if strings.TrimSpace(data.ProxyURL) == "" { + data.ProxyURL = auth.ProxyURL + } + if len(data.Metadata) == 0 { + data.Metadata = cloneAnyMap(auth.Metadata) + } + if len(data.Attributes) == 0 { + data.Attributes = cloneStringMap(auth.Attributes) + } + if len(data.StorageJSON) == 0 { + data.StorageJSON = storageJSONFromAuth(auth) + } + if pluginResp.NextRefreshAfter.IsZero() { + data.NextRefreshAfter = auth.NextRefreshAfter + } else { + data.NextRefreshAfter = pluginResp.NextRefreshAfter + } + next := h.AuthDataToCoreAuth(data, "", data.FileName) + if next == nil { + return nil, true, fmt.Errorf("auth provider refresh returned invalid auth data") + } + next.Index = auth.Index + next.CreatedAt = auth.CreatedAt + next.UpdatedAt = auth.UpdatedAt + return next, true, nil +} + func (h *Host) AuthDataToCoreAuth(data pluginapi.AuthData, path, fileName string) *coreauth.Auth { authDir := "" if h != nil { diff --git a/internal/pluginhost/auth_provider_test.go b/internal/pluginhost/auth_provider_test.go index f6d01e9b..ed349541 100644 --- a/internal/pluginhost/auth_provider_test.go +++ b/internal/pluginhost/auth_provider_test.go @@ -267,6 +267,53 @@ func TestPollLoginPassesProviderStateHostAndHTTPClient(t *testing.T) { } } +func TestRefreshAuthPreservesAuthIndex(t *testing.T) { + host := newHostWithRecords(capabilityRecord{ + id: "auth-plugin", + plugin: pluginapi.Plugin{ + Capabilities: pluginapi.Capabilities{ + AuthProvider: fakeAuthProvider{ + identifier: "plugin-provider", + refreshAuth: func(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) { + if req.AuthID != "auth-1" || req.AuthProvider != "plugin-provider" { + t.Fatalf("RefreshAuth request = %#v, want auth id/provider", req) + } + return pluginapi.AuthRefreshResponse{ + Auth: pluginapi.AuthData{ + Metadata: map[string]any{"access_token": "new-token"}, + }, + }, nil + }, + }, + }, + }, + }) + + auth := host.AuthDataToCoreAuth(pluginapi.AuthData{ + Provider: "plugin-provider", + ID: "auth-1", + Metadata: map[string]any{"access_token": "old-token"}, + }, "", "") + if auth == nil { + t.Fatal("AuthDataToCoreAuth() = nil, want auth") + } + auth.Index = "home-index-1" + + refreshed, handled, errRefresh := host.RefreshAuth(context.Background(), auth) + if errRefresh != nil { + t.Fatalf("RefreshAuth() error = %v", errRefresh) + } + if !handled || refreshed == nil { + t.Fatalf("RefreshAuth() handled=%t auth=%#v, want refreshed auth", handled, refreshed) + } + if refreshed.Index != "home-index-1" { + t.Fatalf("RefreshAuth() index = %q, want home-index-1", refreshed.Index) + } + if got := refreshed.Metadata["access_token"]; got != "new-token" { + t.Fatalf("RefreshAuth() access_token = %q, want new-token", got) + } +} + func TestHostAuthDataToCoreAuthRejectsMissingProviderAndUsesAuthDir(t *testing.T) { authDir := t.TempDir() host := New() diff --git a/internal/pluginstore/github.go b/internal/pluginstore/github.go index 19fc0e59..350d4023 100644 --- a/internal/pluginstore/github.go +++ b/internal/pluginstore/github.go @@ -72,6 +72,33 @@ func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, return release, nil } +// FetchReleaseByTag returns a published release by its exact GitHub tag. +func (c Client) FetchReleaseByTag(ctx context.Context, plugin Plugin, tag string) (Release, error) { + owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository) + if errRepository != nil { + return Release{}, errRepository + } + tag = strings.TrimSpace(tag) + if tag == "" { + return Release{}, fmt.Errorf("release tag is required") + } + releaseURL := fmt.Sprintf( + "https://api.github.com/repos/%s/%s/releases/tags/%s", + url.PathEscape(owner), + url.PathEscape(repo), + url.PathEscape(tag), + ) + data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json") + if errDownload != nil { + return Release{}, errDownload + } + var release Release + if errDecode := json.Unmarshal(data, &release); errDecode != nil { + return Release{}, fmt.Errorf("decode release: %w", errDecode) + } + return release, nil +} + // ReleaseVersion derives the plugin version from the release tag, stripping a // leading "v"/"V" and validating the result. func ReleaseVersion(release Release) (string, error) { diff --git a/internal/pluginstore/install.go b/internal/pluginstore/install.go index 314dee05..932c105f 100644 --- a/internal/pluginstore/install.go +++ b/internal/pluginstore/install.go @@ -11,10 +11,11 @@ import ( "path" "path/filepath" "runtime" + "sort" "strings" - "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" log "github.com/sirupsen/logrus" + "golang.org/x/sys/cpu" ) type InstallOptions struct { @@ -39,6 +40,7 @@ type InstallResult struct { Version string `json:"version"` Path string `json:"path"` Overwritten bool `json:"overwritten"` + Skipped bool `json:"skipped"` } func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptions) (InstallResult, error) { @@ -58,6 +60,42 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio return InstallResult{}, errVersion } plugin.Version = latestVersion + return c.installRelease(ctx, plugin, release, latestVersion, options) +} + +// InstallVersion installs a plugin artifact from a fixed release tag/version. +func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag string, version string, options InstallOptions) (InstallResult, error) { + if errValidate := ValidatePlugin(plugin); errValidate != nil { + return InstallResult{}, errValidate + } + options = normalizeInstallOptions(options) + if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil { + return InstallResult{}, ErrLoadedPluginLocked + } + version = normalizeVersion(version) + if !validPluginVersion(version) { + return InstallResult{}, fmt.Errorf("invalid plugin version %q", version) + } + releaseTag = strings.TrimSpace(releaseTag) + if releaseTag == "" { + releaseTag = version + } + release, errRelease := c.FetchReleaseByTag(ctx, plugin, releaseTag) + if errRelease != nil { + return InstallResult{}, errRelease + } + releaseVersion, errVersion := ReleaseVersion(release) + if errVersion != nil { + return InstallResult{}, errVersion + } + if releaseVersion != version { + return InstallResult{}, fmt.Errorf("release tag %q resolved version %q, want %q", releaseTag, releaseVersion, version) + } + plugin.Version = version + return c.installRelease(ctx, plugin, release, version, options) +} + +func (c Client) installRelease(ctx context.Context, plugin Plugin, release Release, version string, options InstallOptions) (InstallResult, error) { archiveAsset, checksumAsset, errAssets := SelectReleaseAssets(release, plugin.ID, plugin.Version, options.GOOS, options.GOARCH) if errAssets != nil { return InstallResult{}, errAssets @@ -77,13 +115,14 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio if errVerify := VerifyChecksum(archiveAsset.Name, archiveData, checksums); errVerify != nil { return InstallResult{}, errVerify } + plugin.Version = version return InstallArchive(archiveData, plugin, options) } func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) (InstallResult, error) { options = normalizeInstallOptions(options) id := strings.TrimSpace(plugin.ID) - if !pluginhost.ValidatePluginID(id) { + if !validPluginID(id) { return InstallResult{}, fmt.Errorf("invalid plugin id %q", plugin.ID) } reader, errZip := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) @@ -106,6 +145,21 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) ( } else if !errors.Is(errStat, os.ErrNotExist) { return InstallResult{}, fmt.Errorf("stat target plugin: %w", errStat) } + if overwritten { + existingData, errReadExisting := os.ReadFile(targetPath) + if errReadExisting != nil { + return InstallResult{}, fmt.Errorf("read target plugin: %w", errReadExisting) + } + if bytes.Equal(existingData, libraryData) { + return InstallResult{ + ID: id, + Version: strings.TrimSpace(plugin.Version), + Path: targetPath, + Overwritten: true, + Skipped: true, + }, nil + } + } // Re-check immediately before writing: the plugin may have been loaded // while the archive was being downloaded and verified. if options.BeforeWrite != nil { @@ -128,11 +182,11 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) ( } func installTargetPath(options InstallOptions, id string) (string, error) { - defaultPath := filepath.Join(options.PluginsDir, options.GOOS, options.GOARCH, id+pluginhost.PluginExtension(options.GOOS)) + defaultPath := filepath.Join(options.PluginsDir, options.GOOS, options.GOARCH, id+pluginExtension(options.GOOS)) if options.GOOS != runtime.GOOS || options.GOARCH != runtime.GOARCH { return defaultPath, nil } - files, errDiscover := pluginhost.DiscoverPluginFiles(options.PluginsDir) + files, errDiscover := discoverCurrentPluginFiles(options.PluginsDir) if errDiscover != nil { return "", fmt.Errorf("discover current plugin files: %w", errDiscover) } @@ -145,7 +199,7 @@ func installTargetPath(options InstallOptions, id string) (string, error) { } func readTargetLibrary(reader *zip.Reader, id string, goos string) ([]byte, os.FileMode, error) { - targetName := strings.TrimSpace(id) + pluginhost.PluginExtension(goos) + targetName := strings.TrimSpace(id) + pluginExtension(goos) var target *zip.File for _, file := range reader.File { cleanedName, errClean := cleanZipName(file.Name) @@ -223,6 +277,101 @@ func hasDynamicLibraryExtension(name string) bool { return strings.HasSuffix(lowerName, ".dylib") || strings.HasSuffix(lowerName, ".so") || strings.HasSuffix(lowerName, ".dll") } +type pluginFileInfo struct { + ID string + Path string +} + +func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) { + root = strings.TrimSpace(root) + if root == "" { + root = "plugins" + } + candidates := pluginCandidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant()) + extension := pluginExtension(runtime.GOOS) + selected := make([]pluginFileInfo, 0) + seen := make(map[string]struct{}) + for _, dir := range candidates { + entries, errReadDir := os.ReadDir(dir) + if errReadDir != nil { + if os.IsNotExist(errReadDir) { + continue + } + return nil, errReadDir + } + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry == nil || !entry.Type().IsRegular() { + continue + } + if strings.HasSuffix(strings.ToLower(entry.Name()), extension) { + files = append(files, filepath.Join(dir, entry.Name())) + } + } + sort.Strings(files) + for _, path := range files { + id := pluginIDFromPath(path) + if !validPluginID(id) { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + selected = append(selected, pluginFileInfo{ID: id, Path: path}) + } + } + return selected, nil +} + +func pluginCandidateDirs(root string, goos string, goarch string, variant string) []string { + dirs := make([]string, 0, 3) + if variant != "" { + dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant)) + } + dirs = append(dirs, filepath.Join(root, goos, goarch)) + dirs = append(dirs, root) + return dirs +} + +func pluginIDFromPath(path string) string { + base := filepath.Base(path) + lowerBase := strings.ToLower(base) + for _, extension := range []string{".so", ".dylib", ".dll"} { + if strings.HasSuffix(lowerBase, extension) { + return base[:len(base)-len(extension)] + } + } + return base +} + +func pluginExtension(goos string) string { + switch strings.ToLower(strings.TrimSpace(goos)) { + case "darwin", "mac", "macos", "osx": + return ".dylib" + case "windows": + return ".dll" + default: + return ".so" + } +} + +func cpuVariant() string { + if runtime.GOARCH != "amd64" { + return "" + } + if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL { + return "v4" + } + if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA { + return "v3" + } + if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT { + return "v2" + } + return "v1" +} + func writeFileAtomic(targetPath string, data []byte, mode os.FileMode) error { targetDir := filepath.Dir(targetPath) if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { diff --git a/internal/pluginstore/install_test.go b/internal/pluginstore/install_test.go index 573e77bf..422449fa 100644 --- a/internal/pluginstore/install_test.go +++ b/internal/pluginstore/install_test.go @@ -14,8 +14,6 @@ import ( "runtime" "strings" "testing" - - "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" ) func TestInstallBlocksLoadedWindowsPlugin(t *testing.T) { @@ -114,6 +112,53 @@ func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) { } } +func TestInstallArchiveSkipsIdenticalLoadedWindowsPlugin(t *testing.T) { + t.Parallel() + + root := t.TempDir() + targetDir := filepath.Join(root, "windows", "amd64") + if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { + t.Fatalf("MkdirAll() error = %v", errMkdir) + } + targetPath := filepath.Join(targetDir, "sample-provider.dll") + if errWrite := os.WriteFile(targetPath, []byte("same"), 0o644); errWrite != nil { + t.Fatalf("WriteFile() error = %v", errWrite) + } + beforeWriteCalled := false + + result, errInstall := InstallArchive(makeZip(t, map[string]string{ + "sample-provider.dll": "same", + }), testPlugin(), InstallOptions{ + PluginsDir: root, + GOOS: "windows", + GOARCH: "amd64", + PluginLoaded: func() bool { return true }, + BeforeWrite: func() error { + beforeWriteCalled = true + return errors.New("before write should not run") + }, + }) + if errInstall != nil { + t.Fatalf("InstallArchive() error = %v", errInstall) + } + if beforeWriteCalled { + t.Fatal("BeforeWrite was called for identical artifact") + } + if !result.Overwritten { + t.Fatal("Overwritten = false, want true") + } + if !result.Skipped { + t.Fatal("Skipped = false, want true") + } + data, errRead := os.ReadFile(targetPath) + if errRead != nil { + t.Fatalf("ReadFile() error = %v", errRead) + } + if string(data) != "same" { + t.Fatalf("installed data = %q, want same", data) + } +} + func TestInstallArchiveWritesPlatformPlugin(t *testing.T) { t.Parallel() @@ -164,13 +209,13 @@ func TestInstallArchiveOverwritesRuntimeSelectedPlugin(t *testing.T) { t.Parallel() root := t.TempDir() - existingPath := filepath.Join(root, "sample-provider"+pluginhost.PluginExtension(runtime.GOOS)) + existingPath := filepath.Join(root, "sample-provider"+pluginExtension(runtime.GOOS)) if errWrite := os.WriteFile(existingPath, []byte("old"), 0o644); errWrite != nil { t.Fatalf("WriteFile() error = %v", errWrite) } result, errInstall := InstallArchive(makeZip(t, map[string]string{ - "sample-provider" + pluginhost.PluginExtension(runtime.GOOS): "new", + "sample-provider" + pluginExtension(runtime.GOOS): "new", }), testPlugin(), InstallOptions{PluginsDir: root, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}) if errInstall != nil { t.Fatalf("InstallArchive() error = %v", errInstall) @@ -291,6 +336,45 @@ func TestInstallUsesLatestReleaseVersion(t *testing.T) { } } +func TestInstallVersionUsesPinnedReleaseTag(t *testing.T) { + t.Parallel() + + root := t.TempDir() + archiveData := makeZip(t, map[string]string{"sample-provider.so": "library-data"}) + archiveName := "sample-provider_0.3.0_linux_amd64.zip" + checksum := sha256.Sum256(archiveData) + client := Client{HTTPClient: mapHTTPDoer{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.3.0": []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"), + }} + + result, errInstall := client.InstallVersion(context.Background(), testPlugin(), "v0.3.0", "0.3.0", InstallOptions{ + PluginsDir: root, + GOOS: "linux", + GOARCH: "amd64", + }) + if errInstall != nil { + t.Fatalf("InstallVersion() error = %v", errInstall) + } + if result.Version != "0.3.0" { + t.Fatalf("Version = %q, want 0.3.0", result.Version) + } + data, errRead := os.ReadFile(filepath.Join(root, "linux", "amd64", "sample-provider.so")) + if errRead != nil { + t.Fatalf("ReadFile() error = %v", errRead) + } + if string(data) != "library-data" { + t.Fatalf("installed data = %q", data) + } +} + func TestInstallRejectsInvalidLatestReleaseTag(t *testing.T) { t.Parallel() diff --git a/internal/pluginstore/registry.go b/internal/pluginstore/registry.go index 7f611318..8d248fc0 100644 --- a/internal/pluginstore/registry.go +++ b/internal/pluginstore/registry.go @@ -9,8 +9,6 @@ import ( "net/url" "regexp" "strings" - - "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" ) const ( @@ -21,6 +19,7 @@ const ( ) var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`) +var pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) type Source struct { ID string `json:"id"` @@ -159,7 +158,7 @@ func ValidatePlugin(plugin Plugin) error { return fmt.Errorf("missing required field %s", field) } } - if !pluginhost.ValidatePluginID(strings.TrimSpace(plugin.ID)) { + if !validPluginID(strings.TrimSpace(plugin.ID)) { return fmt.Errorf("invalid plugin id %q", plugin.ID) } // The version is optional since the latest release is the source of truth; @@ -177,6 +176,10 @@ func validPluginVersion(version string) bool { return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version) } +func validPluginID(id string) bool { + return pluginIDPattern.MatchString(id) +} + func GitHubRepositoryParts(repository string) (string, string, error) { repository = strings.TrimSpace(repository) parsed, errParse := url.Parse(repository) diff --git a/internal/runtime/executor/helps/home_refresh_test.go b/internal/runtime/executor/helps/home_refresh_test.go index e87c2b41..ca758273 100644 --- a/internal/runtime/executor/helps/home_refresh_test.go +++ b/internal/runtime/executor/helps/home_refresh_test.go @@ -92,4 +92,7 @@ func TestRefreshAuthViaHomeAcceptsAuthEnvelope(t *testing.T) { if got := updated.Metadata["access_token"]; got != "new-access-token" { t.Fatalf("updated access_token = %q, want new-access-token", got) } + if updated.Index != "home-index-1" { + t.Fatalf("updated auth_index = %q, want home-index-1", updated.Index) + } } diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 8c900951..fd6cc878 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -379,8 +379,10 @@ func (a *Auth) EnsureIndex() string { if a == nil { return "" } - if a.indexAssigned && a.Index != "" { - return a.Index + if existingIndex := strings.TrimSpace(a.Index); existingIndex != "" { + a.Index = existingIndex + a.indexAssigned = true + return existingIndex } seed := a.indexSeed() diff --git a/sdk/cliproxy/home_plugins.go b/sdk/cliproxy/home_plugins.go new file mode 100644 index 00000000..44241f4c --- /dev/null +++ b/sdk/cliproxy/home_plugins.go @@ -0,0 +1,15 @@ +package cliproxy + +import ( + "context" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins" +) + +func (s *Service) syncHomePlugins(ctx context.Context, cfg *config.Config) error { + if s == nil || cfg == nil || !cfg.Home.Enabled || !cfg.Plugins.Enabled { + return nil + } + return homeplugins.Sync(ctx, cfg, s.pluginHost) +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 55a0365f..292bbe99 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1424,15 +1424,24 @@ func forceHomeRuntimeConfig(cfg *config.Config) { } func (s *Service) applyHomeOverlay(remoteCfg *config.Config) { + if errApply := s.applyHomeOverlayContext(context.Background(), remoteCfg); errApply != nil { + log.Warnf("failed to apply home config payload: %v", errApply) + } +} + +func (s *Service) applyHomeOverlayContext(ctx context.Context, remoteCfg *config.Config) error { if s == nil || remoteCfg == nil { - return + return nil + } + if ctx == nil { + ctx = context.Background() } s.cfgMu.RLock() baseCfg := s.cfg s.cfgMu.RUnlock() if baseCfg == nil { - return + return nil } merged := *remoteCfg @@ -1443,7 +1452,11 @@ func (s *Service) applyHomeOverlay(remoteCfg *config.Config) { forceHomeRuntimeConfig(&merged) logHomeConfigChanges(baseCfg, &merged) + if errSync := s.syncHomePlugins(ctx, &merged); errSync != nil { + return errSync + } s.applyConfigUpdate(&merged) + return nil } func logHomeConfigChanges(oldCfg, newCfg *config.Config) { @@ -1567,8 +1580,7 @@ func (s *Service) startHomeSubscriber(ctx context.Context) { log.Warnf("failed to parse home config payload: %v", err) return err } - s.applyHomeOverlay(parsed) - return nil + return s.applyHomeOverlayContext(homeCtx, parsed) }) s.startHomeUsageForwarder(homeCtx, client) s.homeLogForwarder = logging.StartHomeAppLogForwarder(0) diff --git a/sdk/pluginhost/host.go b/sdk/pluginhost/host.go new file mode 100644 index 00000000..b9c5405f --- /dev/null +++ b/sdk/pluginhost/host.go @@ -0,0 +1,278 @@ +package pluginhost + +import ( + "context" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + internalpluginhost "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" + internalregistry "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "gopkg.in/yaml.v3" +) + +// ModelInfo describes a plugin-provided model using public plugin SDK types. +type ModelInfo = pluginapi.ModelInfo + +// ThinkingSupport describes plugin-provided thinking controls. +type ThinkingSupport = pluginapi.ThinkingSupport + +// OAuthModelAlias defines a model ID alias for OAuth/file-backed auth channels. +type OAuthModelAlias struct { + Name string + Alias string + Fork bool +} + +// RuntimeConfig is the public plugin host configuration used by embedders. +type RuntimeConfig struct { + Enabled bool + Dir string + AuthDir string + ProxyURL string + ForceModelPrefix bool + OAuthModelAlias map[string][]OAuthModelAlias + OAuthExcludedModels map[string][]string + Configs map[string]PluginInstanceConfig +} + +// PluginInstanceConfig stores host-owned plugin settings and the original plugin YAML subtree. +type PluginInstanceConfig struct { + Enabled *bool + Priority int + Raw yaml.Node +} + +// AuthModelResult is the public result for per-auth model discovery. +type AuthModelResult struct { + Provider string + Models []ModelInfo + Auth *coreauth.Auth + Handled bool + Err error +} + +// Host wraps the internal plugin host behind a public SDK surface. +type Host struct { + inner *internalpluginhost.Host +} + +// New creates a plugin host. +func New() *Host { + return &Host{inner: internalpluginhost.New()} +} + +// ApplyConfig applies plugin runtime configuration. +func (h *Host) ApplyConfig(ctx context.Context, cfg RuntimeConfig) { + if h == nil || h.inner == nil { + return + } + internalCfg := runtimeConfigToInternalConfig(cfg) + h.inner.ApplyConfig(ctx, internalCfg) +} + +// ShutdownAll unloads every active plugin. +func (h *Host) ShutdownAll() { + if h == nil || h.inner == nil { + return + } + h.inner.ShutdownAll() +} + +// ParseAuth lets plugin auth providers parse a credential payload. +func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) { + if h == nil || h.inner == nil { + return nil, false, nil + } + return h.inner.ParseAuth(ctx, req) +} + +// ModelsForAuth lets plugin model providers discover auth-bound models. +func (h *Host) ModelsForAuth(ctx context.Context, auth *coreauth.Auth) AuthModelResult { + if h == nil || h.inner == nil { + return AuthModelResult{} + } + result := h.inner.ModelsForAuth(ctx, auth) + return AuthModelResult{ + Provider: result.Provider, + Models: registryModelsToPluginModels(result.Models), + Auth: result.Auth, + Handled: result.Handled, + Err: result.Err, + } +} + +// ModelsForProvider returns static models registered for a provider by plugins. +func (h *Host) ModelsForProvider(provider string) []ModelInfo { + if h == nil || h.inner == nil { + return nil + } + return registryModelsToPluginModels(h.inner.ModelsForProvider(provider)) +} + +// RefreshAuth lets plugin auth providers refresh a credential. +func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, bool, error) { + if h == nil || h.inner == nil { + return nil, false, nil + } + return h.inner.RefreshAuth(ctx, auth) +} + +// PickAuth lets a scheduler plugin choose an auth candidate. +func (h *Host) PickAuth(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error) { + if h == nil || h.inner == nil { + return pluginapi.SchedulerPickResponse{}, false, nil + } + return h.inner.PickAuth(ctx, req) +} + +// HasScheduler reports whether any active plugin provides a scheduler. +func (h *Host) HasScheduler() bool { + return h != nil && h.inner != nil && h.inner.HasScheduler() +} + +func runtimeConfigToInternalConfig(cfg RuntimeConfig) *internalconfig.Config { + out := &internalconfig.Config{ + SDKConfig: internalconfig.SDKConfig{ + ProxyURL: cfg.ProxyURL, + ForceModelPrefix: cfg.ForceModelPrefix, + }, + AuthDir: cfg.AuthDir, + OAuthExcludedModels: cloneStringSliceMap(cfg.OAuthExcludedModels), + OAuthModelAlias: oauthModelAliasToInternal(cfg.OAuthModelAlias), + Plugins: internalconfig.PluginsConfig{ + Enabled: cfg.Enabled, + Dir: cfg.Dir, + Configs: pluginConfigsToInternal(cfg.Configs), + }, + } + out.NormalizePluginsConfig() + out.SanitizeOAuthModelAlias() + return out +} + +func pluginConfigsToInternal(in map[string]PluginInstanceConfig) map[string]internalconfig.PluginInstanceConfig { + if len(in) == 0 { + return nil + } + out := make(map[string]internalconfig.PluginInstanceConfig, len(in)) + for id, item := range in { + out[id] = internalconfig.PluginInstanceConfig{ + Enabled: item.Enabled, + Priority: item.Priority, + Raw: *deepCopyYAMLNode(&item.Raw), + } + } + return out +} + +func oauthModelAliasToInternal(in map[string][]OAuthModelAlias) map[string][]internalconfig.OAuthModelAlias { + if len(in) == 0 { + return nil + } + out := make(map[string][]internalconfig.OAuthModelAlias, len(in)) + for provider, aliases := range in { + if len(aliases) == 0 { + continue + } + items := make([]internalconfig.OAuthModelAlias, 0, len(aliases)) + for _, alias := range aliases { + items = append(items, internalconfig.OAuthModelAlias{ + Name: alias.Name, + Alias: alias.Alias, + Fork: alias.Fork, + }) + } + out[provider] = items + } + if len(out) == 0 { + return nil + } + return out +} + +func registryModelsToPluginModels(models []*internalregistry.ModelInfo) []ModelInfo { + if len(models) == 0 { + return nil + } + out := make([]ModelInfo, 0, len(models)) + for _, model := range models { + if model == nil { + continue + } + out = append(out, registryModelToPluginModel(model)) + } + return out +} + +func registryModelToPluginModel(model *internalregistry.ModelInfo) ModelInfo { + if model == nil { + return ModelInfo{} + } + return ModelInfo{ + ID: model.ID, + Object: model.Object, + Created: model.Created, + OwnedBy: model.OwnedBy, + Type: model.Type, + DisplayName: model.DisplayName, + Name: model.Name, + Version: model.Version, + Description: model.Description, + InputTokenLimit: int64(model.InputTokenLimit), + OutputTokenLimit: int64(model.OutputTokenLimit), + SupportedGenerationMethods: cloneStringSlice(model.SupportedGenerationMethods), + ContextLength: int64(model.ContextLength), + MaxCompletionTokens: int64(model.MaxCompletionTokens), + SupportedParameters: cloneStringSlice(model.SupportedParameters), + SupportedInputModalities: cloneStringSlice(model.SupportedInputModalities), + SupportedOutputModalities: cloneStringSlice(model.SupportedOutputModalities), + Thinking: thinkingSupportToPlugin(model.Thinking), + UserDefined: model.UserDefined, + } +} + +func thinkingSupportToPlugin(thinking *internalregistry.ThinkingSupport) *ThinkingSupport { + if thinking == nil { + return nil + } + return &ThinkingSupport{ + Min: thinking.Min, + Max: thinking.Max, + ZeroAllowed: thinking.ZeroAllowed, + DynamicAllowed: thinking.DynamicAllowed, + Levels: cloneStringSlice(thinking.Levels), + } +} + +func cloneStringSlice(in []string) []string { + if len(in) == 0 { + return nil + } + return append([]string(nil), in...) +} + +func cloneStringSliceMap(in map[string][]string) map[string][]string { + if len(in) == 0 { + return nil + } + out := make(map[string][]string, len(in)) + for key, values := range in { + out[key] = cloneStringSlice(values) + } + return out +} + +func deepCopyYAMLNode(node *yaml.Node) *yaml.Node { + if node == nil { + return &yaml.Node{} + } + copyNode := *node + if len(node.Content) > 0 { + copyNode.Content = make([]*yaml.Node, 0, len(node.Content)) + for _, child := range node.Content { + copyNode.Content = append(copyNode.Content, deepCopyYAMLNode(child)) + } + } + return ©Node +} diff --git a/sdk/pluginstore/pluginstore.go b/sdk/pluginstore/pluginstore.go new file mode 100644 index 00000000..93785b9c --- /dev/null +++ b/sdk/pluginstore/pluginstore.go @@ -0,0 +1,172 @@ +// Package pluginstore exposes plugin registry and artifact installation helpers +// for embedders such as CLIProxyAPIHome. +package pluginstore + +import ( + "context" + "fmt" + "net/http" + "strings" + + internalpluginstore "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore" +) + +const ( + DefaultRegistryURL = internalpluginstore.DefaultRegistryURL + DefaultSourceID = internalpluginstore.DefaultSourceID + DefaultSourceName = internalpluginstore.DefaultSourceName + SchemaVersion = internalpluginstore.SchemaVersion +) + +type Source = internalpluginstore.Source +type Registry = internalpluginstore.Registry +type Plugin = internalpluginstore.Plugin +type Release = internalpluginstore.Release +type ReleaseAsset = internalpluginstore.ReleaseAsset +type InstallOptions = internalpluginstore.InstallOptions +type InstallResult = internalpluginstore.InstallResult + +type HTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +var ErrLoadedPluginLocked = internalpluginstore.ErrLoadedPluginLocked + +type Client struct { + inner internalpluginstore.Client +} + +type Manifest struct { + ID string `yaml:"id,omitempty" json:"id,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Author string `yaml:"author,omitempty" json:"author,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` + ReleaseTag string `yaml:"release-tag,omitempty" json:"release_tag,omitempty"` + Repository string `yaml:"repository,omitempty" json:"repository,omitempty"` + Logo string `yaml:"logo,omitempty" json:"logo,omitempty"` + Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` + License string `yaml:"license,omitempty" json:"license,omitempty"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + SourceID string `yaml:"source-id,omitempty" json:"source_id,omitempty"` + SourceName string `yaml:"source-name,omitempty" json:"source_name,omitempty"` + SourceURL string `yaml:"source-url,omitempty" json:"source_url,omitempty"` +} + +func NewClient(httpClient HTTPDoer, registryURL string) Client { + return Client{inner: internalpluginstore.Client{ + HTTPClient: httpClient, + RegistryURL: strings.TrimSpace(registryURL), + }} +} + +func DefaultSource() Source { + return internalpluginstore.DefaultSource() +} + +func NormalizeSources(registryURLs []string) ([]Source, error) { + return internalpluginstore.NormalizeSources(registryURLs) +} + +func SourceID(registryURL string) string { + return internalpluginstore.SourceID(registryURL) +} + +func ValidatePlugin(plugin Plugin) error { + return internalpluginstore.ValidatePlugin(plugin) +} + +func UpdateAvailable(installed, latest string) bool { + return internalpluginstore.UpdateAvailable(installed, latest) +} + +func ReleaseVersion(release Release) (string, error) { + return internalpluginstore.ReleaseVersion(release) +} + +func ManifestFromRelease(source Source, plugin Plugin, release Release) (Manifest, error) { + version, errVersion := internalpluginstore.ReleaseVersion(release) + if errVersion != nil { + return Manifest{}, errVersion + } + return Manifest{ + ID: strings.TrimSpace(plugin.ID), + Name: strings.TrimSpace(plugin.Name), + Description: strings.TrimSpace(plugin.Description), + Author: strings.TrimSpace(plugin.Author), + Version: version, + ReleaseTag: strings.TrimSpace(release.TagName), + Repository: strings.TrimSpace(plugin.Repository), + Logo: strings.TrimSpace(plugin.Logo), + Homepage: strings.TrimSpace(plugin.Homepage), + License: strings.TrimSpace(plugin.License), + Tags: append([]string(nil), plugin.Tags...), + SourceID: strings.TrimSpace(source.ID), + SourceName: strings.TrimSpace(source.Name), + SourceURL: strings.TrimSpace(source.URL), + }, nil +} + +func (m Manifest) Plugin() Plugin { + return Plugin{ + ID: strings.TrimSpace(m.ID), + Name: strings.TrimSpace(m.Name), + Description: strings.TrimSpace(m.Description), + Author: strings.TrimSpace(m.Author), + Version: strings.TrimSpace(m.Version), + Repository: strings.TrimSpace(m.Repository), + Logo: strings.TrimSpace(m.Logo), + Homepage: strings.TrimSpace(m.Homepage), + License: strings.TrimSpace(m.License), + Tags: append([]string(nil), m.Tags...), + } +} + +func (m Manifest) Validate() error { + version := strings.TrimSpace(m.Version) + if version == "" { + return fmt.Errorf("missing required field version") + } + releaseTag := strings.TrimSpace(m.ReleaseTag) + if releaseTag == "" { + return fmt.Errorf("missing required field release-tag") + } + if errValidate := internalpluginstore.ValidatePlugin(m.Plugin()); errValidate != nil { + return errValidate + } + releaseVersion, errVersion := internalpluginstore.ReleaseVersion(internalpluginstore.Release{TagName: releaseTag}) + if errVersion != nil { + return errVersion + } + if releaseVersion != version { + return fmt.Errorf("release-tag %q resolves version %q, want %q", releaseTag, releaseVersion, version) + } + return nil +} + +func (c Client) FetchRegistry(ctx context.Context) (Registry, error) { + return c.inner.FetchRegistry(ctx) +} + +func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, error) { + return c.inner.FetchLatestRelease(ctx, plugin) +} + +func (c Client) FetchReleaseByTag(ctx context.Context, plugin Plugin, tag string) (Release, error) { + return c.inner.FetchReleaseByTag(ctx, plugin, tag) +} + +func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptions) (InstallResult, error) { + return c.inner.Install(ctx, plugin, options) +} + +func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag string, version string, options InstallOptions) (InstallResult, error) { + return c.inner.InstallVersion(ctx, plugin, releaseTag, version, options) +} + +func (c Client) InstallManifest(ctx context.Context, manifest Manifest, options InstallOptions) (InstallResult, error) { + if errValidate := manifest.Validate(); errValidate != nil { + return InstallResult{}, errValidate + } + return c.InstallVersion(ctx, manifest.Plugin(), manifest.ReleaseTag, manifest.Version, options) +} diff --git a/sdk/pluginstore/pluginstore_test.go b/sdk/pluginstore/pluginstore_test.go new file mode 100644 index 00000000..607f379c --- /dev/null +++ b/sdk/pluginstore/pluginstore_test.go @@ -0,0 +1,67 @@ +package pluginstore + +import ( + "strings" + "testing" +) + +func TestManifestValidateRequiresPinnedReleaseTag(t *testing.T) { + manifest := validTestManifest() + manifest.ReleaseTag = "" + + errValidate := manifest.Validate() + if errValidate == nil { + t.Fatal("Validate() error = nil, want release-tag error") + } + if !strings.Contains(errValidate.Error(), "release-tag") { + t.Fatalf("Validate() error = %v, want release-tag", errValidate) + } +} + +func TestManifestValidateRejectsReleaseTagVersionMismatch(t *testing.T) { + manifest := validTestManifest() + manifest.ReleaseTag = "v0.3.0" + + errValidate := manifest.Validate() + if errValidate == nil { + t.Fatal("Validate() error = nil, want version mismatch") + } + if !strings.Contains(errValidate.Error(), "resolves version") { + t.Fatalf("Validate() error = %v, want version mismatch", errValidate) + } +} + +func TestManifestFromReleaseBuildsPinnedManifest(t *testing.T) { + manifest, errManifest := ManifestFromRelease( + DefaultSource(), + Plugin{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Repository: "https://github.com/author-name/sample-provider", + }, + Release{TagName: "v0.2.0"}, + ) + if errManifest != nil { + t.Fatalf("ManifestFromRelease() error = %v", errManifest) + } + if errValidate := manifest.Validate(); errValidate != nil { + t.Fatalf("Validate() error = %v", errValidate) + } + if manifest.Version != "0.2.0" || manifest.ReleaseTag != "v0.2.0" { + t.Fatalf("manifest version fields = %q/%q, want 0.2.0/v0.2.0", manifest.Version, manifest.ReleaseTag) + } +} + +func validTestManifest() Manifest { + return Manifest{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Version: "0.2.0", + ReleaseTag: "v0.2.0", + Repository: "https://github.com/author-name/sample-provider", + } +}