diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index 3872a3ff..097f0048 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -199,37 +199,13 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { } pluginIsBusy := func() bool { return pluginBusy(host, id) } - unloadedBeforeWrite := false result, errInstall := client.Install(installCtx, plugin, pluginstore.InstallOptions{ PluginsDir: pluginsDir, GOOS: goos, GOARCH: goarch, PluginLoaded: pluginIsBusy, - BeforeWrite: func() error { - if !pluginIsBusy() { - return nil - } - if host == nil { - return pluginstore.ErrLoadedPluginLocked - } - log.WithFields(log.Fields{ - "plugin_id": id, - "version": plugin.Version, - }).Info("pluginstore: unloading busy plugin before install") - if !host.UnloadPlugin(id) && pluginIsBusy() { - return pluginstore.ErrLoadedPluginLocked - } - unloadedBeforeWrite = true - return nil - }, }) if errInstall != nil { - if unloadedBeforeWrite { - h.mu.Lock() - cfgSnapshot := h.reloadSnapshotConfigLocked() - h.mu.Unlock() - h.reloadConfigAfterManagementSave(c.Request.Context(), cfgSnapshot) - } if errors.Is(errInstall, pluginstore.ErrLoadedPluginLocked) { c.JSON(http.StatusConflict, gin.H{ "error": "plugin_update_requires_restart", diff --git a/internal/homeplugins/sync.go b/internal/homeplugins/sync.go index 02b826a2..4210ca5b 100644 --- a/internal/homeplugins/sync.go +++ b/internal/homeplugins/sync.go @@ -206,15 +206,6 @@ func installManifest(ctx context.Context, client sdkpluginstore.Client, manifest 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 sdkpluginstore.InstallResult{}, fmt.Errorf("home plugins: install %s: %w", id, errInstall) @@ -291,6 +282,7 @@ func currentPluginFilePath(root string, id string) (string, error) { } platform := CurrentPlatform() extension := pluginExtension(platform.GOOS) + var selected pluginFileInfo for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH, platform.Variant) { entries, errReadDir := os.ReadDir(dir) if errReadDir != nil { @@ -310,12 +302,22 @@ func currentPluginFilePath(root string, id string) (string, error) { } sort.Strings(files) for _, filePath := range files { - if pluginIDFromPath(filePath) == id { - return filePath, nil + file, okFile := pluginFileFromPath(filePath, extension) + if !okFile || file.ID != id { + continue + } + if pluginFilePreferred(file, selected) { + selected = file } } } - return "", nil + return selected.Path, nil +} + +type pluginFileInfo struct { + ID string + Path string + Version string } func pluginCandidateDirs(root string, goos string, goarch string, variant string) []string { @@ -329,6 +331,10 @@ func pluginCandidateDirs(root string, goos string, goarch string, variant string } func pluginIDFromPath(path string) string { + file, ok := pluginFileFromPath(path, "") + if ok { + return file.ID + } base := filepath.Base(path) lowerBase := strings.ToLower(base) for _, extension := range []string{".so", ".dylib", ".dll"} { @@ -339,6 +345,55 @@ func pluginIDFromPath(path string) string { return base } +func pluginFileFromPath(filePath string, requiredExtension string) (pluginFileInfo, bool) { + base := filepath.Base(filePath) + lowerBase := strings.ToLower(base) + extension := strings.TrimSpace(requiredExtension) + if extension != "" { + if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) { + return pluginFileInfo{}, false + } + } else { + for _, candidateExtension := range []string{".so", ".dylib", ".dll"} { + if strings.HasSuffix(lowerBase, candidateExtension) { + extension = candidateExtension + break + } + } + if extension == "" { + return pluginFileInfo{}, false + } + } + name := base[:len(base)-len(extension)] + id := name + version := "" + if versionIndex := strings.LastIndex(name, "-v"); versionIndex > 0 { + candidateID := name[:versionIndex] + candidateVersion := name[versionIndex+2:] + if validPluginFileID(candidateID) && validPluginFileVersion(candidateVersion) { + id = candidateID + version = candidateVersion + } + } + if !validPluginFileID(id) { + return pluginFileInfo{}, false + } + return pluginFileInfo{ID: id, Path: filePath, Version: version}, true +} + +func pluginFilePreferred(candidate pluginFileInfo, current pluginFileInfo) bool { + if strings.TrimSpace(current.Path) == "" { + return true + } + if candidate.Version == "" { + return false + } + if current.Version == "" { + return true + } + return sdkpluginstore.UpdateAvailable(current.Version, candidate.Version) +} + func pluginExtension(goos string) string { switch strings.ToLower(strings.TrimSpace(goos)) { case "darwin", "mac", "macos", "osx": @@ -368,6 +423,15 @@ func validPluginFileID(id string) bool { return true } +func validPluginFileVersion(version string) bool { + version = strings.TrimSpace(version) + if version == "" || strings.HasPrefix(version, "v") { + return false + } + first := version[0] + return first >= '0' && first <= '9' +} + func MarkLoadResults(report *SyncReport, inspector PluginLoadInspector) error { if report == nil { return nil diff --git a/internal/pluginhost/adapters.go b/internal/pluginhost/adapters.go index 63fb33de..403a8c1f 100644 --- a/internal/pluginhost/adapters.go +++ b/internal/pluginhost/adapters.go @@ -243,11 +243,12 @@ func (h *Host) RegisterModels(ctx context.Context, modelRegistry modelRegistry) } snap := h.Snapshot() + records := h.activeRecordsFromSnapshot(snap) registrations := make([]modelClientRegistration, 0) nextClients := make(map[string]struct{}) nextProviders := make(map[string]string) nextModelRegistrations := make(map[string]pluginModelRegistration) - for _, record := range snap.records { + for _, record := range records { modelProvider := record.plugin.Capabilities.ModelProvider registrar := record.plugin.Capabilities.ModelRegistrar if modelProvider == nil && registrar == nil { @@ -320,7 +321,7 @@ func (h *Host) ModelsForAuth(ctx context.Context, auth *coreauth.Auth) AuthModel if providerKey == "" { return AuthModelResult{} } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { modelProvider := record.plugin.Capabilities.ModelProvider if modelProvider == nil || h.isPluginFused(record.id) { continue @@ -458,7 +459,7 @@ type modelClientRegistration struct { } func (h *Host) callModelRegistrar(ctx context.Context, record capabilityRecord, registrar pluginapi.ModelRegistrar) (resp pluginapi.ModelRegistrationResponse, err error) { - if h == nil || registrar == nil || h.isPluginFused(record.id) { + if h == nil || registrar == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.ModelRegistrationResponse{}, nil } defer func() { @@ -472,7 +473,7 @@ func (h *Host) callModelRegistrar(ctx context.Context, record capabilityRecord, } func (h *Host) callModelProviderStaticModels(ctx context.Context, record capabilityRecord, provider pluginapi.ModelProvider) (resp pluginapi.ModelResponse, err error) { - if h == nil || provider == nil || h.isPluginFused(record.id) { + if h == nil || provider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.ModelResponse{}, nil } defer func() { @@ -489,7 +490,7 @@ func (h *Host) callModelProviderStaticModels(ctx context.Context, record capabil } func (h *Host) callModelsForAuth(ctx context.Context, record capabilityRecord, provider pluginapi.ModelProvider, auth *coreauth.Auth) (resp pluginapi.ModelResponse, err error) { - if h == nil || provider == nil || auth == nil || h.isPluginFused(record.id) { + if h == nil || provider == nil || auth == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.ModelResponse{}, nil } defer func() { @@ -511,58 +512,58 @@ func (h *Host) callModelsForAuth(ctx context.Context, record capabilityRecord, p }) } -func (h *Host) callRequestInterceptor(ctx context.Context, pluginID, method string, call func(context.Context, pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error), req pluginapi.RequestInterceptRequest) (out pluginapi.RequestInterceptResponse, ok bool) { - if h == nil || call == nil || h.isPluginFused(pluginID) { +func (h *Host) callRequestInterceptor(ctx context.Context, record capabilityRecord, method string, call func(context.Context, pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error), req pluginapi.RequestInterceptRequest) (out pluginapi.RequestInterceptResponse, ok bool) { + if h == nil || call == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.RequestInterceptResponse{}, false } defer func() { if recovered := recover(); recovered != nil { - h.fusePlugin(pluginID, method, recovered) + h.fusePlugin(record.id, method, recovered) out = pluginapi.RequestInterceptResponse{} ok = false } }() resp, errIntercept := call(ctx, req) if errIntercept != nil { - log.Warnf("pluginhost: request interceptor %s failed: %v", pluginID, errIntercept) + log.Warnf("pluginhost: request interceptor %s failed: %v", record.id, errIntercept) return pluginapi.RequestInterceptResponse{}, false } return resp, true } -func (h *Host) callResponseInterceptor(ctx context.Context, pluginID string, interceptor pluginapi.ResponseInterceptor, req pluginapi.ResponseInterceptRequest) (out pluginapi.ResponseInterceptResponse, ok bool) { - if h == nil || interceptor == nil || h.isPluginFused(pluginID) { +func (h *Host) callResponseInterceptor(ctx context.Context, record capabilityRecord, interceptor pluginapi.ResponseInterceptor, req pluginapi.ResponseInterceptRequest) (out pluginapi.ResponseInterceptResponse, ok bool) { + if h == nil || interceptor == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.ResponseInterceptResponse{}, false } defer func() { if recovered := recover(); recovered != nil { - h.fusePlugin(pluginID, "ResponseInterceptor.InterceptResponse", recovered) + h.fusePlugin(record.id, "ResponseInterceptor.InterceptResponse", recovered) out = pluginapi.ResponseInterceptResponse{} ok = false } }() resp, errIntercept := interceptor.InterceptResponse(ctx, req) if errIntercept != nil { - log.Warnf("pluginhost: response interceptor %s failed: %v", pluginID, errIntercept) + log.Warnf("pluginhost: response interceptor %s failed: %v", record.id, errIntercept) return pluginapi.ResponseInterceptResponse{}, false } return resp, true } -func (h *Host) callStreamChunkInterceptor(ctx context.Context, pluginID string, interceptor pluginapi.StreamChunkInterceptor, req pluginapi.StreamChunkInterceptRequest) (out pluginapi.StreamChunkInterceptResponse, ok bool) { - if h == nil || interceptor == nil || h.isPluginFused(pluginID) { +func (h *Host) callStreamChunkInterceptor(ctx context.Context, record capabilityRecord, interceptor pluginapi.StreamChunkInterceptor, req pluginapi.StreamChunkInterceptRequest) (out pluginapi.StreamChunkInterceptResponse, ok bool) { + if h == nil || interceptor == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.StreamChunkInterceptResponse{}, false } defer func() { if recovered := recover(); recovered != nil { - h.fusePlugin(pluginID, "StreamChunkInterceptor.InterceptStreamChunk", recovered) + h.fusePlugin(record.id, "StreamChunkInterceptor.InterceptStreamChunk", recovered) out = pluginapi.StreamChunkInterceptResponse{} ok = false } }() resp, errIntercept := interceptor.InterceptStreamChunk(ctx, req) if errIntercept != nil { - log.Warnf("pluginhost: stream chunk interceptor %s failed: %v", pluginID, errIntercept) + log.Warnf("pluginhost: stream chunk interceptor %s failed: %v", record.id, errIntercept) return pluginapi.StreamChunkInterceptResponse{}, false } return resp, true @@ -594,7 +595,7 @@ func (h *Host) interceptRequest(ctx context.Context, req pluginapi.RequestInterc Body: bytes.Clone(req.Body), } skipPluginID = strings.TrimSpace(skipPluginID) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { interceptor := record.plugin.Capabilities.RequestInterceptor if h.isPluginFused(record.id) || interceptor == nil || record.id == skipPluginID { continue @@ -603,7 +604,7 @@ func (h *Host) interceptRequest(ctx context.Context, req pluginapi.RequestInterc nextReq.Headers = cloneHeader(current.Headers) nextReq.Body = bytes.Clone(current.Body) nextReq.Metadata = cloneInterceptorMetadata(req.Metadata) - if resp, ok := h.callRequestInterceptor(ctx, record.id, method, func(callCtx context.Context, callReq pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) { + if resp, ok := h.callRequestInterceptor(ctx, record, method, func(callCtx context.Context, callReq pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) { return invoke(interceptor, callCtx, callReq) }, nextReq); ok { current.Headers = mergeHeaders(current.Headers, resp.Headers, resp.ClearHeaders) @@ -625,7 +626,7 @@ func (h *Host) InterceptResponseExcept(ctx context.Context, req pluginapi.Respon Body: bytes.Clone(req.Body), } skipPluginID = strings.TrimSpace(skipPluginID) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { interceptor := record.plugin.Capabilities.ResponseInterceptor if h.isPluginFused(record.id) || interceptor == nil || record.id == skipPluginID { continue @@ -637,7 +638,7 @@ func (h *Host) InterceptResponseExcept(ctx context.Context, req pluginapi.Respon nextReq.RequestBody = bytes.Clone(req.RequestBody) nextReq.Body = bytes.Clone(current.Body) nextReq.Metadata = cloneInterceptorMetadata(req.Metadata) - if resp, ok := h.callResponseInterceptor(ctx, record.id, interceptor, nextReq); ok { + if resp, ok := h.callResponseInterceptor(ctx, record, interceptor, nextReq); ok { current.Headers = mergeHeaders(current.Headers, resp.Headers, resp.ClearHeaders) if len(resp.Body) > 0 { current.Body = bytes.Clone(resp.Body) @@ -657,7 +658,7 @@ func (h *Host) InterceptStreamChunkExcept(ctx context.Context, req pluginapi.Str Body: bytes.Clone(req.Body), } skipPluginID = strings.TrimSpace(skipPluginID) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { interceptor := record.plugin.Capabilities.StreamChunkInterceptor if h.isPluginFused(record.id) || interceptor == nil || current.DropChunk || record.id == skipPluginID { continue @@ -670,7 +671,7 @@ func (h *Host) InterceptStreamChunkExcept(ctx context.Context, req pluginapi.Str nextReq.Body = bytes.Clone(current.Body) nextReq.HistoryChunks = cloneByteSlices(req.HistoryChunks) nextReq.Metadata = cloneInterceptorMetadata(req.Metadata) - if resp, ok := h.callStreamChunkInterceptor(ctx, record.id, interceptor, nextReq); ok { + if resp, ok := h.callStreamChunkInterceptor(ctx, record, interceptor, nextReq); ok { current.Headers = mergeHeaders(current.Headers, resp.Headers, resp.ClearHeaders) if len(resp.Body) > 0 { current.Body = bytes.Clone(resp.Body) @@ -687,7 +688,7 @@ func (h *Host) HasStreamInterceptors() bool { if h == nil { return false } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if h.isPluginFused(record.id) { continue } @@ -702,7 +703,7 @@ func (h *Host) HasRequestInterceptors() bool { if h == nil { return false } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if h.isPluginFused(record.id) { continue } @@ -759,6 +760,7 @@ func (h *Host) RegisterExecutors(manager executorManager, modelRegistry modelPro } snap := h.Snapshot() + records := h.activeRecordsFromSnapshot(snap) registrations := h.snapshotModelRegistrations() selectedModels := make(map[string][]*registry.ModelInfo) providerModels := make(map[string][]*registry.ModelInfo) @@ -769,7 +771,7 @@ func (h *Host) RegisterExecutors(manager executorManager, modelRegistry modelPro appendModelsForProvider(providerModels, registration.provider, registration.models) } } - for _, record := range snap.records { + for _, record := range records { executor := record.plugin.Capabilities.Executor if executor == nil || h.isPluginFused(record.id) { continue @@ -811,7 +813,7 @@ func (h *Host) RegisterExecutors(manager executorManager, modelRegistry modelPro nextModelClients := make(map[string]struct{}) executorRegistrations := make([]executorRegistration, 0) modelClientRegistrations := make([]modelClientRegistration, 0) - for _, record := range snap.records { + for _, record := range records { executor := record.plugin.Capabilities.Executor if executor == nil || h.isPluginFused(record.id) { continue @@ -919,6 +921,8 @@ func newExecutorAdapterRegistration(h *Host, record capabilityRecord, provider s adapter: &executorAdapter{ host: h, pluginID: record.id, + path: record.path, + version: record.version, provider: provider, executor: executor, inputFormats: normalizeExecutorFormats(record.plugin.Capabilities.ExecutorInputFormats), @@ -959,6 +963,9 @@ func (h *Host) modelRegistration(pluginID string) pluginModelRegistration { } func (h *Host) executorProvider(record capabilityRecord, executor pluginapi.ProviderExecutor) (string, bool) { + if h == nil || !h.recordCurrent(record) { + return "", false + } provider := h.modelProvider(record.id) if provider == "" { identifier, okIdentifier := h.callExecutorIdentifier(record.id, executor) @@ -1053,7 +1060,7 @@ func (h *Host) HasExecutorCandidateProvider(provider string) bool { if provider == "" { return false } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { executor := record.plugin.Capabilities.Executor if executor == nil || h.isPluginFused(record.id) { continue @@ -1093,7 +1100,7 @@ func (h *Host) RegisterFrontendAuthProviders() { nextKeys := make(map[string]struct{}) var bestExclusive exclusiveFrontendAuthCandidate - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { provider := record.plugin.Capabilities.FrontendAuthProvider if provider == nil || h.isPluginFused(record.id) { continue @@ -1101,6 +1108,8 @@ func (h *Host) RegisterFrontendAuthProviders() { adapter := &accessAdapter{ host: h, pluginID: record.id, + path: record.path, + version: record.version, provider: provider, } key := strings.TrimSpace(adapter.Identifier()) @@ -1156,7 +1165,7 @@ func (h *Host) RegisterUsagePlugins() { return } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { plugin := record.plugin.Capabilities.UsagePlugin if plugin == nil || h.isPluginFused(record.id) { continue @@ -1186,6 +1195,8 @@ func (h *Host) refreshThinkingProviders(records []capabilityRecord) { thinking.RegisterPluginProvider(record.id, provider, record.priority, &thinkingAdapter{ host: h, pluginID: record.id, + path: record.path, + version: record.version, provider: provider, applier: applier, }) @@ -1193,6 +1204,9 @@ func (h *Host) refreshThinkingProviders(records []capabilityRecord) { } func (h *Host) callThinkingIdentifier(record capabilityRecord, applier pluginapi.ThinkingApplier) (provider string, ok bool) { + if h == nil || applier == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { + return "", false + } defer func() { if recovered := recover(); recovered != nil { h.fusePlugin(record.id, "ThinkingApplier.Identifier", recovered) @@ -1211,7 +1225,7 @@ func (h *Host) currentUsagePlugin(pluginID string) pluginapi.UsagePlugin { if h == nil || strings.TrimSpace(pluginID) == "" { return nil } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if record.id != pluginID { continue } @@ -1247,6 +1261,8 @@ func (h *Host) isPluginFused(id string) bool { type accessAdapter struct { host *Host pluginID string + path string + version string provider pluginapi.FrontendAuthProvider } @@ -1271,7 +1287,7 @@ func (a *accessAdapter) Identifier() (identifier string) { } func (a *accessAdapter) Authenticate(ctx context.Context, r *http.Request) (result *sdkaccess.Result, authErr *sdkaccess.AuthError) { - if a == nil || a.provider == nil || a.host.isPluginFused(a.pluginID) { + if a == nil || a.provider == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) { return nil, sdkaccess.NewNotHandledError() } defer func() { @@ -1310,6 +1326,8 @@ func (a *accessAdapter) Authenticate(ctx context.Context, r *http.Request) (resu type executorAdapter struct { host *Host pluginID string + path string + version string provider string executor pluginapi.ProviderExecutor inputFormats []sdktranslator.Format @@ -1439,7 +1457,7 @@ func (a *executorAdapter) executorResponseTranslationAvailable(from, to sdktrans } func (h *Host) hasResponseTranslator() bool { - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if h.isPluginFused(record.id) || record.plugin.Capabilities.ResponseTranslator == nil { continue } @@ -1572,7 +1590,7 @@ func sendExecutorPluginStreamChunk(ctx context.Context, out chan<- pluginapi.Exe } func (a *executorAdapter) Execute(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (resp coreexecutor.Response, err error) { - if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) { return coreexecutor.Response{}, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) } defer func() { @@ -1599,7 +1617,7 @@ func (a *executorAdapter) Execute(ctx context.Context, auth *coreauth.Auth, req } func (a *executorAdapter) ExecuteStream(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (result *coreexecutor.StreamResult, err error) { - if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) { return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) } defer func() { @@ -1625,7 +1643,7 @@ func (a *executorAdapter) ExecuteStream(ctx context.Context, auth *coreauth.Auth } func (a *executorAdapter) Refresh(ctx context.Context, auth *coreauth.Auth) (refreshed *coreauth.Auth, err error) { - if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) { return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) } record := a.host.authProviderRecord(authProvider(auth)) @@ -1698,7 +1716,7 @@ func (a *executorAdapter) Refresh(ctx context.Context, auth *coreauth.Auth) (ref } func (a *executorAdapter) CountTokens(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (resp coreexecutor.Response, err error) { - if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) { return coreexecutor.Response{}, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) } defer func() { @@ -1725,7 +1743,7 @@ func (a *executorAdapter) CountTokens(ctx context.Context, auth *coreauth.Auth, } func (a *executorAdapter) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (resp *http.Response, err error) { - if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) { return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) } if req == nil { @@ -1780,6 +1798,8 @@ type usageAdapter struct { type thinkingAdapter struct { host *Host pluginID string + path string + version string provider string applier pluginapi.ThinkingApplier } @@ -1831,7 +1851,7 @@ func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record) } func (a *thinkingAdapter) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) (out []byte, err error) { - if a == nil || a.applier == nil || a.host == nil || a.host.isPluginFused(a.pluginID) { + if a == nil || a.applier == nil || a.host == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) { return bytes.Clone(body), nil } defer func() { @@ -1859,7 +1879,7 @@ func (a *thinkingAdapter) Apply(body []byte, config thinking.ThinkingConfig, mod func (h *Host) NormalizeRequest(ctx context.Context, from, to sdktranslator.Format, model string, body []byte, stream bool) []byte { current := bytes.Clone(body) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if h.isPluginFused(record.id) || record.plugin.Capabilities.RequestNormalizer == nil { continue } @@ -1871,7 +1891,7 @@ func (h *Host) NormalizeRequest(ctx context.Context, from, to sdktranslator.Form } func (h *Host) TranslateRequest(ctx context.Context, from, to sdktranslator.Format, model string, body []byte, stream bool) ([]byte, bool) { - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if h.isPluginFused(record.id) || record.plugin.Capabilities.RequestTranslator == nil { continue } @@ -1884,12 +1904,12 @@ func (h *Host) TranslateRequest(ctx context.Context, from, to sdktranslator.Form func (h *Host) NormalizeResponseBefore(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte { current := bytes.Clone(body) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { normalizer := record.plugin.Capabilities.ResponseBeforeTranslator if h.isPluginFused(record.id) || normalizer == nil { continue } - if normalized, ok := h.callResponseNormalizer(ctx, record.id, "ResponseBeforeTranslator.NormalizeResponse", normalizer, from, to, model, originalRequestRawJSON, requestRawJSON, current, stream); ok { + if normalized, ok := h.callResponseNormalizer(ctx, record, "ResponseBeforeTranslator.NormalizeResponse", normalizer, from, to, model, originalRequestRawJSON, requestRawJSON, current, stream); ok { current = normalized } } @@ -1897,12 +1917,12 @@ func (h *Host) NormalizeResponseBefore(ctx context.Context, from, to sdktranslat } func (h *Host) TranslateResponse(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) ([]byte, bool) { - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { translator := record.plugin.Capabilities.ResponseTranslator if h.isPluginFused(record.id) || translator == nil { continue } - if translated, ok := h.callResponseTranslator(ctx, record.id, translator, from, to, model, originalRequestRawJSON, requestRawJSON, body, stream); ok { + if translated, ok := h.callResponseTranslator(ctx, record, translator, from, to, model, originalRequestRawJSON, requestRawJSON, body, stream); ok { return translated, true } } @@ -1911,12 +1931,12 @@ func (h *Host) TranslateResponse(ctx context.Context, from, to sdktranslator.For func (h *Host) NormalizeResponseAfter(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte { current := bytes.Clone(body) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { normalizer := record.plugin.Capabilities.ResponseAfterTranslator if h.isPluginFused(record.id) || normalizer == nil { continue } - if normalized, ok := h.callResponseNormalizer(ctx, record.id, "ResponseAfterTranslator.NormalizeResponse", normalizer, from, to, model, originalRequestRawJSON, requestRawJSON, current, stream); ok { + if normalized, ok := h.callResponseNormalizer(ctx, record, "ResponseAfterTranslator.NormalizeResponse", normalizer, from, to, model, originalRequestRawJSON, requestRawJSON, current, stream); ok { current = normalized } } @@ -1924,6 +1944,9 @@ func (h *Host) NormalizeResponseAfter(ctx context.Context, from, to sdktranslato } func (h *Host) callRequestNormalizer(ctx context.Context, record capabilityRecord, from, to sdktranslator.Format, model string, body []byte, stream bool) (out []byte, ok bool) { + if h == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) || record.plugin.Capabilities.RequestNormalizer == nil { + return nil, false + } defer func() { if recovered := recover(); recovered != nil { h.fusePlugin(record.id, "RequestNormalizer.NormalizeRequest", recovered) @@ -1945,6 +1968,9 @@ func (h *Host) callRequestNormalizer(ctx context.Context, record capabilityRecor } func (h *Host) callRequestTranslator(ctx context.Context, record capabilityRecord, from, to sdktranslator.Format, model string, body []byte, stream bool) (out []byte, ok bool) { + if h == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) || record.plugin.Capabilities.RequestTranslator == nil { + return nil, false + } defer func() { if recovered := recover(); recovered != nil { h.fusePlugin(record.id, "RequestTranslator.TranslateRequest", recovered) @@ -1965,10 +1991,13 @@ func (h *Host) callRequestTranslator(ctx context.Context, record capabilityRecor return bytes.Clone(resp.Body), true } -func (h *Host) callResponseNormalizer(ctx context.Context, pluginID, method string, normalizer pluginapi.ResponseNormalizer, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) (out []byte, ok bool) { +func (h *Host) callResponseNormalizer(ctx context.Context, record capabilityRecord, method string, normalizer pluginapi.ResponseNormalizer, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) (out []byte, ok bool) { + if h == nil || normalizer == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { + return nil, false + } defer func() { if recovered := recover(); recovered != nil { - h.fusePlugin(pluginID, method, recovered) + h.fusePlugin(record.id, method, recovered) out = nil ok = false } @@ -1988,10 +2017,13 @@ func (h *Host) callResponseNormalizer(ctx context.Context, pluginID, method stri return bytes.Clone(resp.Body), true } -func (h *Host) callResponseTranslator(ctx context.Context, pluginID string, translator pluginapi.ResponseTranslator, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) (out []byte, ok bool) { +func (h *Host) callResponseTranslator(ctx context.Context, record capabilityRecord, translator pluginapi.ResponseTranslator, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) (out []byte, ok bool) { + if h == nil || translator == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { + return nil, false + } defer func() { if recovered := recover(); recovered != nil { - h.fusePlugin(pluginID, "ResponseTranslator.TranslateResponse", recovered) + h.fusePlugin(record.id, "ResponseTranslator.TranslateResponse", recovered) out = nil ok = false } diff --git a/internal/pluginhost/auth_provider.go b/internal/pluginhost/auth_provider.go index 0be9925e..68752b40 100644 --- a/internal/pluginhost/auth_provider.go +++ b/internal/pluginhost/auth_provider.go @@ -110,7 +110,7 @@ func (h *Host) AuthProviderIdentifiers() []string { return nil } out := make([]string, 0) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { provider := record.plugin.Capabilities.AuthProvider if provider == nil || h.isPluginFused(record.id) { continue @@ -132,7 +132,7 @@ func (h *Host) authProviderRecord(provider string) *capabilityRecord { if h == nil || provider == "" { return nil } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { authProvider := record.plugin.Capabilities.AuthProvider if authProvider == nil || h.isPluginFused(record.id) { continue @@ -179,7 +179,7 @@ func (h *Host) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ( } return h.callParseAuths(ctx, *record, req) } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if record.plugin.Capabilities.AuthProvider == nil || h.isPluginFused(record.id) { continue } @@ -201,7 +201,7 @@ func (h *Host) callParseAuth(ctx context.Context, record capabilityRecord, req p func (h *Host) callParseAuths(ctx context.Context, record capabilityRecord, req pluginapi.AuthParseRequest) (auths []*coreauth.Auth, handled bool, err error) { provider := record.plugin.Capabilities.AuthProvider - if h == nil || provider == nil || h.isPluginFused(record.id) { + if h == nil || provider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return nil, false, nil } defer func() { @@ -265,7 +265,7 @@ func (h *Host) StartLogin(ctx context.Context, provider string, baseURL string) func (h *Host) callStartLogin(ctx context.Context, record capabilityRecord, provider string, baseURL string) (resp pluginapi.AuthLoginStartResponse, handled bool, err error) { authProvider := record.plugin.Capabilities.AuthProvider - if h == nil || authProvider == nil || h.isPluginFused(record.id) { + if h == nil || authProvider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.AuthLoginStartResponse{}, false, nil } defer func() { @@ -303,7 +303,7 @@ func (h *Host) PollLogin(ctx context.Context, provider, state string, metadata . func (h *Host) callPollLogin(ctx context.Context, record capabilityRecord, provider, state string, metadata map[string]any) (resp pluginapi.AuthLoginPollResponse, handled bool, err error) { authProvider := record.plugin.Capabilities.AuthProvider - if h == nil || authProvider == nil || h.isPluginFused(record.id) { + if h == nil || authProvider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.AuthLoginPollResponse{}, false, nil } defer func() { @@ -336,6 +336,9 @@ func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (refreshed if record == nil || record.plugin.Capabilities.AuthProvider == nil { return nil, false, nil } + if !h.recordCurrent(*record) { + return nil, false, nil + } defer func() { if recovered := recover(); recovered != nil { h.fusePlugin(record.id, "AuthProvider.RefreshAuth", recovered) diff --git a/internal/pluginhost/command_line.go b/internal/pluginhost/command_line.go index 91fb5722..52311702 100644 --- a/internal/pluginhost/command_line.go +++ b/internal/pluginhost/command_line.go @@ -28,7 +28,7 @@ func (h *Host) RegisterCommandLineFlags(ctx context.Context, flagSet *flag.FlagS return } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { plugin := record.plugin.Capabilities.CommandLinePlugin if plugin == nil || h.isPluginFused(record.id) { continue @@ -45,7 +45,7 @@ func (h *Host) RegisterCommandLineFlags(ctx context.Context, flagSet *flag.FlagS } func (h *Host) callCommandLineRegistrar(ctx context.Context, record capabilityRecord, plugin pluginapi.CommandLinePlugin) (resp pluginapi.CommandLineRegistrationResponse, err error) { - if h == nil || plugin == nil || h.isPluginFused(record.id) { + if h == nil || plugin == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.CommandLineRegistrationResponse{}, nil } defer func() { @@ -247,7 +247,7 @@ func (h *Host) ExecuteCommandLine(ctx context.Context, program string, args []st exitCode := 0 handled := false - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { plugin := record.plugin.Capabilities.CommandLinePlugin if plugin == nil || h.isPluginFused(record.id) { continue @@ -349,7 +349,7 @@ func cloneCommandLineFlagValues(in map[string]pluginapi.CommandLineFlagValue) ma } func (h *Host) callCommandLineExecutor(ctx context.Context, record capabilityRecord, plugin pluginapi.CommandLinePlugin, req pluginapi.CommandLineExecutionRequest) (resp pluginapi.CommandLineExecutionResponse, err error) { - if h == nil || plugin == nil || h.isPluginFused(record.id) { + if h == nil || plugin == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.CommandLineExecutionResponse{}, nil } defer func() { diff --git a/internal/pluginhost/executor_route.go b/internal/pluginhost/executor_route.go index fceb37aa..be6138db 100644 --- a/internal/pluginhost/executor_route.go +++ b/internal/pluginhost/executor_route.go @@ -27,7 +27,7 @@ func (h *Host) executorPluginReady(pluginID string, routeReq pluginapi.ModelRout if pluginID == "" { return false } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if record.id != pluginID || h.isPluginFused(record.id) { continue } @@ -117,7 +117,7 @@ func (h *Host) executorAdapterForPlugin(pluginID string) (*executorAdapter, erro if pluginID == "" { return nil, fmt.Errorf("target executor plugin id is required") } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if record.id != pluginID { continue } diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index be52f772..259d0dc8 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -3,6 +3,7 @@ package pluginhost import ( "context" "fmt" + "path/filepath" "strings" "sync" "sync/atomic" @@ -19,6 +20,7 @@ import ( type loadedPlugin struct { id string path string + version string registered bool client pluginClient } @@ -29,9 +31,10 @@ type modelExecutor interface { } type pluginUnloadTarget struct { - id string - path string - client pluginClient + id string + path string + version string + client pluginClient } type Host struct { @@ -39,8 +42,13 @@ type Host struct { mu sync.Mutex loader pluginLoader loaded map[string]*loadedPlugin + retired map[string][]*loadedPlugin loading map[string]struct{} fused map[string]string + pluginFileVersions map[string]string + activePluginVersions map[string]string + activePluginPaths map[string]string + cleanupFilesPending bool runtimeConfig *config.Config authManager *coreauth.Manager modelExecutor modelExecutor @@ -66,8 +74,13 @@ func New() *Host { h := &Host{ loader: defaultPluginLoader(), loaded: make(map[string]*loadedPlugin), + retired: make(map[string][]*loadedPlugin), loading: make(map[string]struct{}), fused: make(map[string]string), + pluginFileVersions: make(map[string]string), + activePluginVersions: make(map[string]string), + activePluginPaths: make(map[string]string), + cleanupFilesPending: true, modelClientIDs: make(map[string]struct{}), executorModelClientIDs: make(map[string]struct{}), modelProviders: make(map[string]string), @@ -136,7 +149,10 @@ func (h *Host) PluginLoaded(id string) bool { h.mu.Lock() defer h.mu.Unlock() _, ok := h.loaded[id] - return ok + if ok { + return true + } + return len(h.retired[id]) > 0 } // PluginBusy reports whether a plugin dynamic library is loaded or being loaded. @@ -153,6 +169,9 @@ func (h *Host) PluginBusy(id string) bool { if _, ok := h.loaded[id]; ok { return true } + if len(h.retired[id]) > 0 { + return true + } _, ok := h.loading[id] return ok } @@ -173,6 +192,7 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { h.mu.Lock() h.managementRoutes = make(map[string]managementRouteRecord) h.resourceRoutes = make(map[string]resourceRouteRecord) + h.rebuildActivePluginMapsLocked(nil) h.snapshot.Store(emptySnapshot()) h.mu.Unlock() h.refreshThinkingProviders(nil) @@ -185,6 +205,7 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { h.mu.Lock() h.managementRoutes = make(map[string]managementRouteRecord) h.resourceRoutes = make(map[string]resourceRouteRecord) + h.rebuildActivePluginMapsLocked(nil) h.snapshot.Store(emptySnapshot()) h.mu.Unlock() h.refreshThinkingProviders(nil) @@ -192,6 +213,7 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { } records := make([]capabilityRecord, 0, len(files)) + loadedFiles := make([]pluginFile, 0, len(files)) for _, file := range files { item, ok := rc.Items[file.ID] if !ok { @@ -202,9 +224,14 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { } h.mu.Lock() lp := h.loaded[file.ID] + var replaced *loadedPlugin + if lp != nil && cleanPluginPath(lp.path) != cleanPluginPath(file.Path) { + replaced = lp + lp = nil + } _, disabled := h.fused[file.ID] h.mu.Unlock() - if disabled { + if disabled && replaced == nil { continue } @@ -224,10 +251,16 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { // ApplyConfig, UnloadPlugin, and ShutdownAll are serialized by applyMu, // so a nil read cannot race into a duplicate load. lp = loaded + if replaced != nil { + h.retireLoadedPluginLocked(replaced) + delete(h.fused, file.ID) + h.removePluginRuntimeStateLocked(file.ID) + } h.loaded[file.ID] = lp h.mu.Unlock() log.WithFields(log.Fields{ "plugin_id": file.ID, + "version": file.Version, "path": file.Path, }).Info("pluginhost: plugin loaded") } @@ -239,17 +272,30 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { plugin.Metadata = clonePluginMetadata(plugin.Metadata) records = append(records, capabilityRecord{ id: file.ID, + path: file.Path, + version: file.Version, priority: item.Priority, meta: plugin.Metadata, plugin: plugin, }) + loadedFiles = append(loadedFiles, file) } sortRecords(records) h.mu.Lock() + cleanupFiles := h.cleanupFilesPending + if len(loadedFiles) > 0 { + h.cleanupFilesPending = false + } + h.rebuildActivePluginMapsLocked(records) h.snapshot.Store(&Snapshot{enabled: true, records: records}) h.mu.Unlock() h.refreshThinkingProviders(records) + if cleanupFiles && len(loadedFiles) > 0 { + if errCleanup := cleanupUnselectedPluginFiles(rc.Dir, loadedFiles); errCleanup != nil { + log.Warnf("pluginhost: failed to clean old plugin files: %v", errCleanup) + } + } } func (h *Host) load(file pluginFile) (*loadedPlugin, error) { @@ -259,9 +305,10 @@ func (h *Host) load(file pluginFile) (*loadedPlugin, error) { } return &loadedPlugin{ - id: file.ID, - path: file.Path, - client: newGuardedPluginClient(client), + id: file.ID, + path: file.Path, + version: file.Version, + client: newGuardedPluginClient(client), }, nil } @@ -278,16 +325,30 @@ func (h *Host) UnloadPlugin(id string) bool { h.applyMu.Lock() defer h.applyMu.Unlock() - var target pluginUnloadTarget + targets := make([]pluginUnloadTarget, 0) h.mu.Lock() lp := h.loaded[id] - if lp == nil { + if lp != nil { + targets = append(targets, pluginUnloadTarget{id: lp.id, path: lp.path, version: lp.version, client: lp.client}) + } + for _, retired := range h.retired[id] { + if retired == nil { + continue + } + targets = append(targets, pluginUnloadTarget{id: retired.id, path: retired.path, version: retired.version, client: retired.client}) + } + if len(targets) == 0 { h.mu.Unlock() return false } - target = pluginUnloadTarget{id: lp.id, path: lp.path, client: lp.client} delete(h.loaded, id) + delete(h.retired, id) delete(h.fused, id) + delete(h.activePluginVersions, id) + delete(h.activePluginPaths, id) + for _, target := range targets { + delete(h.pluginFileVersions, cleanPluginPath(target.path)) + } records, enabled := h.snapshotWithoutPluginLocked(id) h.removePluginRuntimeStateLocked(id) h.snapshot.Store(&Snapshot{enabled: enabled, records: records}) @@ -295,13 +356,16 @@ func (h *Host) UnloadPlugin(id string) bool { h.refreshThinkingProviders(records) h.RegisterFrontendAuthProviders() - if target.client != nil { - target.client.Shutdown() + for _, target := range targets { + if target.client != nil { + target.client.Shutdown() + } + log.WithFields(log.Fields{ + "plugin_id": target.id, + "version": target.version, + "path": target.path, + }).Info("pluginhost: plugin unloaded") } - log.WithFields(log.Fields{ - "plugin_id": target.id, - "path": target.path, - }).Info("pluginhost: plugin unloaded") return true } @@ -321,12 +385,27 @@ func (h *Host) ShutdownAll() { continue } targets = append(targets, pluginUnloadTarget{ - id: lp.id, - path: lp.path, - client: lp.client, + id: lp.id, + path: lp.path, + version: lp.version, + client: lp.client, }) } + for _, retiredPlugins := range h.retired { + for _, lp := range retiredPlugins { + if lp == nil || lp.client == nil { + continue + } + targets = append(targets, pluginUnloadTarget{ + id: lp.id, + path: lp.path, + version: lp.version, + client: lp.client, + }) + } + } h.loaded = make(map[string]*loadedPlugin) + h.retired = make(map[string][]*loadedPlugin) h.loading = make(map[string]struct{}) h.modelClientIDs = make(map[string]struct{}) h.executorModelClientIDs = make(map[string]struct{}) @@ -338,6 +417,9 @@ func (h *Host) ShutdownAll() { h.commandLineHits = make(map[string]struct{}) h.managementRoutes = make(map[string]managementRouteRecord) h.resourceRoutes = make(map[string]resourceRouteRecord) + h.pluginFileVersions = make(map[string]string) + h.activePluginVersions = make(map[string]string) + h.activePluginPaths = make(map[string]string) h.snapshot.Store(emptySnapshot()) h.mu.Unlock() @@ -347,11 +429,53 @@ func (h *Host) ShutdownAll() { target.client.Shutdown() log.WithFields(log.Fields{ "plugin_id": target.id, + "version": target.version, "path": target.path, }).Info("pluginhost: plugin unloaded") } } +func cleanPluginPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + return filepath.Clean(path) +} + +func (h *Host) retireLoadedPluginLocked(lp *loadedPlugin) { + if h == nil || lp == nil { + return + } + h.retired[lp.id] = append(h.retired[lp.id], lp) +} + +func (h *Host) recordCurrent(record capabilityRecord) bool { + return h.pluginIdentityCurrent(record.id, record.path, record.version) +} + +func (h *Host) pluginIdentityCurrent(id string, path string, version string) bool { + if h == nil { + return false + } + version = strings.TrimSpace(version) + h.mu.Lock() + defer h.mu.Unlock() + id = strings.TrimSpace(id) + if id == "" { + return false + } + path = cleanPluginPath(path) + if path == "" || h.activePluginPaths[id] != path { + return false + } + activePathVersion, okVersion := h.pluginFileVersions[path] + if !okVersion || activePathVersion != version { + return false + } + return h.activePluginVersions[id] == version +} + func (h *Host) snapshotWithoutPluginLocked(id string) ([]capabilityRecord, bool) { raw := h.snapshot.Load() snap, _ := raw.(*Snapshot) @@ -392,6 +516,22 @@ func (h *Host) removePluginRuntimeStateLocked(id string) { delete(h.modelRegistrations, id) } +func (h *Host) rebuildActivePluginMapsLocked(records []capabilityRecord) { + h.pluginFileVersions = make(map[string]string, len(records)) + h.activePluginVersions = make(map[string]string, len(records)) + h.activePluginPaths = make(map[string]string, len(records)) + for _, record := range records { + id := strings.TrimSpace(record.id) + path := cleanPluginPath(record.path) + if id == "" || path == "" { + continue + } + h.pluginFileVersions[path] = strings.TrimSpace(record.version) + h.activePluginVersions[id] = strings.TrimSpace(record.version) + h.activePluginPaths[id] = path + } +} + func (h *Host) callRegister(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) { if lp == nil { return pluginapi.Plugin{}, false diff --git a/internal/pluginhost/host_test.go b/internal/pluginhost/host_test.go index 1eeb5bcb..4845b2e0 100644 --- a/internal/pluginhost/host_test.go +++ b/internal/pluginhost/host_test.go @@ -71,8 +71,8 @@ func TestHostApplyConfig_DisabledPluginSkipsCapability(t *testing.T) { if loader.openCalls != 0 { t.Fatalf("Open calls = %d, want 0", loader.openCalls) } - if len(h.Snapshot().records) != 0 { - t.Fatalf("Snapshot records = %d, want 0", len(h.Snapshot().records)) + if len(h.activeRecords()) != 0 { + t.Fatalf("Snapshot records = %d, want 0", len(h.activeRecords())) } } @@ -95,8 +95,8 @@ func TestHostApplyConfig_DefaultDisabledPluginSkipsLoad(t *testing.T) { if plugin.registerCalls != 0 || loader.openCalls != 0 { t.Fatalf("calls = register %d open %d, want 0", plugin.registerCalls, loader.openCalls) } - if len(h.Snapshot().records) != 0 { - t.Fatalf("Snapshot records = %d, want 0", len(h.Snapshot().records)) + if len(h.activeRecords()) != 0 { + t.Fatalf("Snapshot records = %d, want 0", len(h.activeRecords())) } } @@ -286,8 +286,8 @@ func TestHostApplyConfigRegistersInterceptorOnlyPlugin(t *testing.T) { }, }) - if len(h.Snapshot().records) != 1 { - t.Fatalf("Snapshot records = %d, want 1", len(h.Snapshot().records)) + if len(h.activeRecords()) != 1 { + t.Fatalf("Snapshot records = %d, want 1", len(h.activeRecords())) } } @@ -329,11 +329,11 @@ func TestHostApplyConfigDispatchesInterceptorRPCMethods(t *testing.T) { }, }) - if len(h.Snapshot().records) != 1 { - t.Fatalf("Snapshot records = %d, want 1", len(h.Snapshot().records)) + if len(h.activeRecords()) != 1 { + t.Fatalf("Snapshot records = %d, want 1", len(h.activeRecords())) } - caps := h.Snapshot().records[0].plugin.Capabilities + caps := h.activeRecords()[0].plugin.Capabilities reqResp, errReq := caps.RequestInterceptor.InterceptRequestBeforeAuth(context.Background(), pluginapi.RequestInterceptRequest{Body: []byte("request")}) if errReq != nil { t.Fatalf("InterceptRequestBeforeAuth() error = %v", errReq) @@ -548,8 +548,8 @@ func TestHostApplyConfig_ReconfigureCalledOnReload(t *testing.T) { if loader.openCalls != 1 { t.Fatalf("Open calls = %d, want 1", loader.openCalls) } - if len(h.Snapshot().records) != 1 { - t.Fatalf("Snapshot records = %d, want 1", len(h.Snapshot().records)) + if len(h.activeRecords()) != 1 { + t.Fatalf("Snapshot records = %d, want 1", len(h.activeRecords())) } } @@ -617,8 +617,8 @@ func TestHostApplyConfig_InvalidMetadataOrNoCapabilitiesSkipped(t *testing.T) { }, }) - if len(h.Snapshot().records) != 0 { - t.Fatalf("Snapshot records = %d, want 0", len(h.Snapshot().records)) + if len(h.activeRecords()) != 0 { + t.Fatalf("Snapshot records = %d, want 0", len(h.activeRecords())) } } @@ -650,8 +650,8 @@ func TestHostApplyConfig_PanicFusesPluginForProcessLifetime(t *testing.T) { if plugin.reconfigureCalls != 1 { t.Fatalf("Reconfigure calls = %d, want 1", plugin.reconfigureCalls) } - if len(h.Snapshot().records) != 0 { - t.Fatalf("Snapshot records = %d, want 0 after fuse", len(h.Snapshot().records)) + if len(h.activeRecords()) != 0 { + t.Fatalf("Snapshot records = %d, want 0 after fuse", len(h.activeRecords())) } } diff --git a/internal/pluginhost/management.go b/internal/pluginhost/management.go index a0b7f0d6..3857e9bc 100644 --- a/internal/pluginhost/management.go +++ b/internal/pluginhost/management.go @@ -21,11 +21,15 @@ const ( type managementRouteRecord struct { pluginID string + path string + version string route pluginapi.ManagementRoute } type resourceRouteRecord struct { pluginID string + path string + version string route pluginapi.ResourceRoute } @@ -37,7 +41,7 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string nextRoutes := make(map[string]managementRouteRecord) nextResources := make(map[string]resourceRouteRecord) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { plugin := record.plugin.Capabilities.ManagementAPI if plugin == nil || h.isPluginFused(record.id) { continue @@ -55,7 +59,7 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string continue } if routeDeclaresLegacyMenuResource(method, item) { - if !registerResourceRoute(nextResources, record.id, resourceRouteFromManagementRoute(item)) { + if !registerResourceRoute(nextResources, record, resourceRouteFromManagementRoute(item)) { log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path) } continue @@ -73,12 +77,14 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string item.Path = path nextRoutes[key] = managementRouteRecord{ pluginID: record.id, + path: record.path, + version: record.version, route: item, } } for _, item := range resp.Resources { - if !registerResourceRoute(nextResources, record.id, item) { + if !registerResourceRoute(nextResources, record, item) { log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path) } } @@ -91,7 +97,7 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string } func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRecord, plugin pluginapi.ManagementAPI) (resp pluginapi.ManagementRegistrationResponse, err error) { - if h == nil || plugin == nil || h.isPluginFused(record.id) { + if h == nil || plugin == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.ManagementRegistrationResponse{}, nil } defer func() { @@ -157,19 +163,21 @@ func resourceRouteFromManagementRoute(item pluginapi.ManagementRoute) pluginapi. } } -func registerResourceRoute(routes map[string]resourceRouteRecord, pluginID string, item pluginapi.ResourceRoute) bool { - path, okRoute := normalizeResourceRoute(pluginID, item) +func registerResourceRoute(routes map[string]resourceRouteRecord, record capabilityRecord, item pluginapi.ResourceRoute) bool { + path, okRoute := normalizeResourceRoute(record.id, item) if !okRoute { return false } key := managementRouteKey(http.MethodGet, path) if _, exists := routes[key]; exists { - log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", pluginID, key) + log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", record.id, key) return true } item.Path = path routes[key] = resourceRouteRecord{ - pluginID: pluginID, + pluginID: record.id, + path: record.path, + version: record.version, route: item, } return true @@ -319,7 +327,7 @@ func (h *Host) ServeResourceHTTP(w http.ResponseWriter, r *http.Request) bool { } func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) { - if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) { + if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) || !h.pluginIdentityCurrent(record.pluginID, record.path, record.version) { return pluginapi.ManagementResponse{}, nil } defer func() { @@ -341,7 +349,7 @@ func escapeManagementResponseBody(resp pluginapi.ManagementResponse) []byte { } func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) { - if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) { + if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) || !h.pluginIdentityCurrent(record.pluginID, record.path, record.version) { return pluginapi.ManagementResponse{}, nil } defer func() { diff --git a/internal/pluginhost/model_router.go b/internal/pluginhost/model_router.go index 6886f220..80d0d61d 100644 --- a/internal/pluginhost/model_router.go +++ b/internal/pluginhost/model_router.go @@ -22,7 +22,7 @@ func (h *Host) HasModelRoutersExcept(skipPluginID string) bool { return false } skipPluginID = strings.TrimSpace(skipPluginID) - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if record.plugin.Capabilities.ModelRouter != nil && !h.isPluginFused(record.id) && record.id != skipPluginID { return true } @@ -36,7 +36,7 @@ func (h *Host) RouteModelExcept(ctx context.Context, req pluginapi.ModelRouteReq } skipPluginID = strings.TrimSpace(skipPluginID) req.AvailableProviders = h.availableProvidersSnapshot() - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { router := record.plugin.Capabilities.ModelRouter if router == nil || h.isPluginFused(record.id) || record.id == skipPluginID { continue diff --git a/internal/pluginhost/platform.go b/internal/pluginhost/platform.go index 5926a96a..a5a81d0d 100644 --- a/internal/pluginhost/platform.go +++ b/internal/pluginhost/platform.go @@ -1,27 +1,35 @@ package pluginhost import ( + "errors" "os" "path/filepath" "regexp" "runtime" "sort" + "strconv" "strings" + log "github.com/sirupsen/logrus" "golang.org/x/sys/cpu" ) -var pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) +var ( + pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`) +) type pluginFile struct { - ID string - Path string + ID string + Path string + Version string } // PluginFileInfo describes a plugin binary selected by the host discovery rules. type PluginFileInfo struct { - ID string - Path string + ID string + Path string + Version string } // ValidatePluginID reports whether id can be used as a plugin configuration key. @@ -33,7 +41,15 @@ func validPluginID(id string) bool { return pluginIDPattern.MatchString(id) } +func validPluginVersion(version string) bool { + return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version) +} + func pluginIDFromPath(path string) string { + file, ok := pluginFileFromPath(path, "") + if ok { + return file.ID + } base := filepath.Base(path) lowerBase := strings.ToLower(base) for _, extension := range []string{".so", ".dylib", ".dll"} { @@ -44,6 +60,42 @@ func pluginIDFromPath(path string) string { return base } +func pluginFileFromPath(filePath string, requiredExtension string) (pluginFile, bool) { + base := filepath.Base(filePath) + lowerBase := strings.ToLower(base) + extension := strings.TrimSpace(requiredExtension) + if extension != "" { + if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) { + return pluginFile{}, false + } + } else { + for _, candidateExtension := range []string{".so", ".dylib", ".dll"} { + if strings.HasSuffix(lowerBase, candidateExtension) { + extension = candidateExtension + break + } + } + if extension == "" { + return pluginFile{}, false + } + } + name := base[:len(base)-len(extension)] + id := name + version := "" + if versionIndex := strings.LastIndex(name, "-v"); versionIndex > 0 { + candidateID := name[:versionIndex] + candidateVersion := name[versionIndex+2:] + if validPluginID(candidateID) && validPluginVersion(candidateVersion) { + id = candidateID + version = candidateVersion + } + } + if !validPluginID(id) { + return pluginFile{}, false + } + return pluginFile{ID: id, Path: filePath, Version: version}, true +} + // PluginExtension returns the dynamic library file extension used for goos. func PluginExtension(goos string) string { return pluginExtension(goos) @@ -61,6 +113,11 @@ func pluginExtension(goos string) string { } func selectPluginFiles(root string) ([]pluginFile, error) { + selected, _, errSelect := selectPluginFilesWithCandidates(root) + return selected, errSelect +} + +func selectPluginFilesWithCandidates(root string) ([]pluginFile, []pluginFile, error) { root = strings.TrimSpace(root) if root == "" { root = "plugins" @@ -68,15 +125,16 @@ func selectPluginFiles(root string) ([]pluginFile, error) { candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant()) extension := pluginExtension(runtime.GOOS) - selected := make([]pluginFile, 0) - seen := make(map[string]struct{}) + selectedByID := make(map[string]pluginFile) + order := make([]string, 0) + all := make([]pluginFile, 0) for _, dir := range candidates { entries, errReadDir := os.ReadDir(dir) if errReadDir != nil { if os.IsNotExist(errReadDir) { continue } - return nil, errReadDir + return nil, nil, errReadDir } files := make([]string, 0, len(entries)) for _, entry := range entries { @@ -89,18 +147,118 @@ func selectPluginFiles(root string) ([]pluginFile, error) { } sort.Strings(files) for _, path := range files { - id := pluginIDFromPath(path) - if !validPluginID(id) { + file, okFile := pluginFileFromPath(path, extension) + if !okFile { continue } - if _, exists := seen[id]; exists { + all = append(all, file) + current, exists := selectedByID[file.ID] + if !exists { + selectedByID[file.ID] = file + order = append(order, file.ID) continue } - seen[id] = struct{}{} - selected = append(selected, pluginFile{ID: id, Path: path}) + if pluginFilePreferred(file, current) { + selectedByID[file.ID] = file + } + } + } + selected := make([]pluginFile, 0, len(order)) + for _, id := range order { + selected = append(selected, selectedByID[id]) + } + return selected, all, nil +} + +func pluginFilePreferred(candidate pluginFile, current pluginFile) bool { + if candidate.Version == "" { + return false + } + if current.Version == "" { + return true + } + comparison, comparable := comparePluginVersions(candidate.Version, current.Version) + if !comparable { + return candidate.Version > current.Version + } + return comparison > 0 +} + +func comparePluginVersions(a, b string) (int, bool) { + segmentsA := strings.Split(a, ".") + segmentsB := strings.Split(b, ".") + length := len(segmentsA) + if len(segmentsB) > length { + length = len(segmentsB) + } + for index := 0; index < length; index++ { + numberA, okA := pluginVersionSegment(segmentsA, index) + numberB, okB := pluginVersionSegment(segmentsB, index) + if !okA || !okB { + return 0, false + } + if numberA != numberB { + if numberA < numberB { + return -1, true + } + return 1, true + } + } + return 0, true +} + +func pluginVersionSegment(segments []string, index int) (int64, bool) { + if index >= len(segments) { + return 0, true + } + number, errParse := strconv.ParseInt(segments[index], 10, 64) + if errParse != nil || number < 0 { + return 0, false + } + return number, true +} + +func cleanupUnselectedPluginFiles(root string, loaded []pluginFile) error { + if len(loaded) == 0 { + return nil + } + _, candidates, errSelect := selectPluginFilesWithCandidates(root) + if errSelect != nil { + return errSelect + } + loadedByID := make(map[string]map[string]struct{}, len(loaded)) + for _, file := range loaded { + if strings.TrimSpace(file.ID) == "" || strings.TrimSpace(file.Path) == "" { + continue + } + paths := loadedByID[file.ID] + if paths == nil { + paths = make(map[string]struct{}) + loadedByID[file.ID] = paths + } + paths[filepath.Clean(file.Path)] = struct{}{} + } + var errs []error + for _, candidate := range candidates { + paths := loadedByID[candidate.ID] + if len(paths) == 0 { + continue + } + if _, selected := paths[filepath.Clean(candidate.Path)]; selected { + continue + } + if errRemove := os.Remove(candidate.Path); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) { + errs = append(errs, errRemove) + log.WithError(errRemove).Warnf("pluginhost: failed to remove old plugin file %s", candidate.Path) + continue } + log.WithFields(log.Fields{ + "plugin_id": candidate.ID, + "version": candidate.Version, + "path": candidate.Path, + }).Info("pluginhost: old plugin file removed") } - return selected, nil + return errors.Join(errs...) } // DiscoverPluginFiles returns plugin binaries selected by the current host discovery rules. @@ -112,8 +270,9 @@ func DiscoverPluginFiles(root string) ([]PluginFileInfo, error) { out := make([]PluginFileInfo, 0, len(files)) for _, file := range files { out = append(out, PluginFileInfo{ - ID: file.ID, - Path: file.Path, + ID: file.ID, + Path: file.Path, + Version: file.Version, }) } return out, nil diff --git a/internal/pluginhost/scheduler.go b/internal/pluginhost/scheduler.go index 33781fb0..a5d44240 100644 --- a/internal/pluginhost/scheduler.go +++ b/internal/pluginhost/scheduler.go @@ -38,7 +38,7 @@ func (h *Host) schedulerRecord() *capabilityRecord { if h == nil { return nil } - for _, record := range h.Snapshot().records { + for _, record := range h.activeRecords() { if h.isPluginFused(record.id) || record.plugin.Capabilities.Scheduler == nil { continue } @@ -50,7 +50,7 @@ func (h *Host) schedulerRecord() *capabilityRecord { func (h *Host) callScheduler(ctx context.Context, record capabilityRecord, req pluginapi.SchedulerPickRequest) (resp pluginapi.SchedulerPickResponse, handled bool, err error) { scheduler := record.plugin.Capabilities.Scheduler - if h == nil || scheduler == nil || h.isPluginFused(record.id) { + if h == nil || scheduler == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) { return pluginapi.SchedulerPickResponse{}, false, nil } defer func() { diff --git a/internal/pluginhost/snapshot.go b/internal/pluginhost/snapshot.go index 514805ec..ccc10acc 100644 --- a/internal/pluginhost/snapshot.go +++ b/internal/pluginhost/snapshot.go @@ -9,6 +9,8 @@ import ( type capabilityRecord struct { id string + path string + version string priority int meta pluginapi.Metadata plugin pluginapi.Plugin @@ -39,15 +41,32 @@ func emptySnapshot() *Snapshot { return &Snapshot{} } +func (h *Host) activeRecords() []capabilityRecord { + return h.activeRecordsFromSnapshot(h.Snapshot()) +} + +func (h *Host) activeRecordsFromSnapshot(snap *Snapshot) []capabilityRecord { + if snap == nil || len(snap.records) == 0 { + return nil + } + out := make([]capabilityRecord, 0, len(snap.records)) + for _, record := range snap.records { + if h.recordCurrent(record) { + out = append(out, record) + } + } + return out +} + // RegisteredPlugins returns a stable copy of plugin metadata in the current runtime snapshot. func (h *Host) RegisteredPlugins() []RegisteredPluginInfo { - snap := h.Snapshot() - if snap == nil || len(snap.records) == 0 { + records := h.activeRecords() + if len(records) == 0 { return nil } menusByPlugin := h.registeredPluginMenus() - out := make([]RegisteredPluginInfo, 0, len(snap.records)) - for _, record := range snap.records { + out := make([]RegisteredPluginInfo, 0, len(records)) + for _, record := range records { out = append(out, RegisteredPluginInfo{ ID: record.id, Priority: record.priority, @@ -68,11 +87,7 @@ func (h *Host) PluginRegistered(id string) bool { if id == "" { return false } - snap := h.Snapshot() - if snap == nil || len(snap.records) == 0 { - return false - } - for _, record := range snap.records { + for _, record := range h.activeRecords() { if record.id == id { return true } diff --git a/internal/pluginstore/install.go b/internal/pluginstore/install.go index 932c105f..67ad320b 100644 --- a/internal/pluginstore/install.go +++ b/internal/pluginstore/install.go @@ -23,11 +23,11 @@ type InstallOptions struct { GOOS string GOARCH string // PluginLoaded reports whether the plugin's dynamic library is currently - // loaded by the running host. Windows installs are rejected while it returns - // true unless BeforeWrite can unload the plugin before replacement. + // loaded by the running host. Windows installs are rejected only when they + // would overwrite an existing target file while it returns true. PluginLoaded func() bool // BeforeWrite runs after the archive has been downloaded and verified, but - // before the target plugin file is replaced. + // before an existing target plugin file is replaced. BeforeWrite func() error } @@ -48,9 +48,6 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio return InstallResult{}, errValidate } options = normalizeInstallOptions(options) - if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil { - return InstallResult{}, ErrLoadedPluginLocked - } release, errRelease := c.FetchLatestRelease(ctx, plugin) if errRelease != nil { return InstallResult{}, errRelease @@ -69,9 +66,6 @@ func (c Client) InstallVersion(ctx context.Context, plugin Plugin, releaseTag st 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) @@ -125,17 +119,22 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) ( if !validPluginID(id) { return InstallResult{}, fmt.Errorf("invalid plugin id %q", plugin.ID) } + version := normalizeVersion(plugin.Version) + if !validPluginVersion(version) { + return InstallResult{}, fmt.Errorf("invalid plugin version %q", plugin.Version) + } + plugin.Version = version reader, errZip := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) if errZip != nil { return InstallResult{}, fmt.Errorf("open zip: %w", errZip) } - libraryData, mode, errLibrary := readTargetLibrary(reader, id, options.GOOS) + libraryData, mode, errLibrary := readTargetLibrary(reader, id, version, options.GOOS) if errLibrary != nil { return InstallResult{}, errLibrary } - targetPath, errTarget := installTargetPath(options, id) + targetPath, errTarget := installTargetPath(options, id, version) if errTarget != nil { return InstallResult{}, errTarget } @@ -160,14 +159,14 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) ( }, nil } } - // Re-check immediately before writing: the plugin may have been loaded - // while the archive was being downloaded and verified. - if options.BeforeWrite != nil { + // Re-check immediately before replacing an existing file: the same version + // may have been loaded while the archive was being downloaded and verified. + if overwritten && options.BeforeWrite != nil { if errBeforeWrite := options.BeforeWrite(); errBeforeWrite != nil { return InstallResult{}, fmt.Errorf("prepare plugin write: %w", errBeforeWrite) } } - if loadedPluginInstallBlocked(options) { + if overwritten && loadedPluginInstallBlocked(options) { return InstallResult{}, ErrLoadedPluginLocked } if errWrite := writeFileAtomic(targetPath, libraryData, mode); errWrite != nil { @@ -181,25 +180,17 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) ( }, nil } -func installTargetPath(options InstallOptions, id string) (string, error) { - 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 := discoverCurrentPluginFiles(options.PluginsDir) - if errDiscover != nil { - return "", fmt.Errorf("discover current plugin files: %w", errDiscover) - } - for _, file := range files { - if file.ID == id && strings.TrimSpace(file.Path) != "" { - return file.Path, nil - } +func installTargetPath(options InstallOptions, id string, version string) (string, error) { + version = normalizeVersion(version) + if !validPluginVersion(version) { + return "", fmt.Errorf("invalid plugin version %q", version) } - return defaultPath, nil + return filepath.Join(options.PluginsDir, options.GOOS, options.GOARCH, versionedPluginFileName(id, version, options.GOOS)), nil } -func readTargetLibrary(reader *zip.Reader, id string, goos string) ([]byte, os.FileMode, error) { +func readTargetLibrary(reader *zip.Reader, id string, version string, goos string) ([]byte, os.FileMode, error) { targetName := strings.TrimSpace(id) + pluginExtension(goos) + versionedTargetName := versionedPluginFileName(id, version, goos) var target *zip.File for _, file := range reader.File { cleanedName, errClean := cleanZipName(file.Name) @@ -215,11 +206,11 @@ func readTargetLibrary(reader *zip.Reader, id string, goos string) ([]byte, os.F if !hasDynamicLibraryExtension(cleanedName) { continue } - if cleanedName != targetName { - if path.Base(cleanedName) == targetName { + if cleanedName != targetName && cleanedName != versionedTargetName { + if path.Base(cleanedName) == targetName || path.Base(cleanedName) == versionedTargetName { return nil, 0, fmt.Errorf("target dynamic library must be at zip root") } - return nil, 0, fmt.Errorf("dynamic library filename must be %s", targetName) + return nil, 0, fmt.Errorf("dynamic library filename must be %s or %s", targetName, versionedTargetName) } if target != nil { return nil, 0, fmt.Errorf("zip contains multiple target dynamic libraries") @@ -250,6 +241,10 @@ func readTargetLibrary(reader *zip.Reader, id string, goos string) ([]byte, os.F return data, mode, nil } +func versionedPluginFileName(id string, version string, goos string) string { + return strings.TrimSpace(id) + "-v" + normalizeVersion(version) + pluginExtension(goos) +} + func cleanZipName(name string) (string, error) { if strings.TrimSpace(name) == "" { return "", fmt.Errorf("zip entry has empty name") @@ -278,8 +273,9 @@ func hasDynamicLibraryExtension(name string) bool { } type pluginFileInfo struct { - ID string - Path string + ID string + Path string + Version string } func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) { @@ -310,15 +306,15 @@ func discoverCurrentPluginFiles(root string) ([]pluginFileInfo, error) { } sort.Strings(files) for _, path := range files { - id := pluginIDFromPath(path) - if !validPluginID(id) { + file, okFile := pluginFileInfoFromPath(path, extension) + if !okFile { continue } - if _, exists := seen[id]; exists { + if _, exists := seen[file.ID]; exists { continue } - seen[id] = struct{}{} - selected = append(selected, pluginFileInfo{ID: id, Path: path}) + seen[file.ID] = struct{}{} + selected = append(selected, file) } } return selected, nil @@ -335,6 +331,10 @@ func pluginCandidateDirs(root string, goos string, goarch string, variant string } func pluginIDFromPath(path string) string { + file, ok := pluginFileInfoFromPath(path, "") + if ok { + return file.ID + } base := filepath.Base(path) lowerBase := strings.ToLower(base) for _, extension := range []string{".so", ".dylib", ".dll"} { @@ -345,6 +345,42 @@ func pluginIDFromPath(path string) string { return base } +func pluginFileInfoFromPath(filePath string, requiredExtension string) (pluginFileInfo, bool) { + base := filepath.Base(filePath) + lowerBase := strings.ToLower(base) + extension := strings.TrimSpace(requiredExtension) + if extension != "" { + if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) { + return pluginFileInfo{}, false + } + } else { + for _, candidateExtension := range []string{".so", ".dylib", ".dll"} { + if strings.HasSuffix(lowerBase, candidateExtension) { + extension = candidateExtension + break + } + } + if extension == "" { + return pluginFileInfo{}, false + } + } + name := base[:len(base)-len(extension)] + id := name + version := "" + if versionIndex := strings.LastIndex(name, "-v"); versionIndex > 0 { + candidateID := name[:versionIndex] + candidateVersion := name[versionIndex+2:] + if validPluginID(candidateID) && validPluginVersion(candidateVersion) { + id = candidateID + version = candidateVersion + } + } + if !validPluginID(id) { + return pluginFileInfo{}, false + } + return pluginFileInfo{ID: id, Path: filePath, Version: version}, true +} + func pluginExtension(goos string) string { switch strings.ToLower(strings.TrimSpace(goos)) { case "darwin", "mac", "macos", "osx":