navidrome/plugins/manager.go
Deluan Quintão 5b87a60b5d
fix(plugins): load plugin agents in CLI commands (#5959)
* feat(plugins): load plugin agents in CLI commands

A CLI that goes through core/agents saw only built-in agents: getEnabledAgentNames asks
Manager.PluginNames, which reads a map populated solely by Manager.Start, and only the
server calls that. On a plugin-using install the CLI's agent list was quietly short —
artwork explain --live could name deezer for an artist whose stored source was
external:apple-music, because apple-music was invisible to it.

Adds Manager.LoadPlugins, the read-only counterpart to Start: extism/wazero init plus
loadEnabledPlugins, without the folder sync, the error clearing, the cache purge or the
watcher. It follows what the read-only plugin commands already do — list, info and
validate read the DB and never start the manager — except that capabilities are detected
from the WASM exports, not declared in the manifest, so instantiating is the only accurate
source for what a plugin provides.

loadEnabledPlugins disabled a plugin and recorded LastError when a load failed. That is
right for the server and wrong for a diagnostic, so it is now gated on the read-only flag:
inspecting a plugin must not disable it.

No Subsonic router is required. Start log.Fatals without one, but the host function it
feeds already nil-checks and reports 'SubsonicAPI router not available' at call time, so a
plugin that reaches for it gets an error instead of the process dying. That Fatal's message
also claimed the DataStore was missing; it checks the router.

* fix(cli): correct the reprocess estimate's plugin caveat

imageAgentCount now receives a manager with plugins loaded, so the external estimate
already includes plugin image agents — but the disclaimer still said they were not
counted, which told operators the opposite of what the number meant.

Replaced rather than dropped: loadPluginAgents warns and continues when LoadPlugins
fails, and LoadPlugins is a no-op when plugins are disabled or no folder is set, so
there are still runs where plugin agents genuinely are not counted. The wording now
covers all three cases, and the stale comment above it said the CLI never starts the
plugin manager, which is what this branch changed.

Found by Codex on 648cf38e9.

* fix(plugins): load only the configured agents, and gate plugin init

Loading a plugin is not free: the service constructors create a KVStore or TaskQueue
database and a Storage directory for any plugin whose manifest declares those
permissions, and the plugin's own init then runs arbitrary code. loadEnabledPlugins
loads every enabled plugin, so inspecting artwork was starting scrobblers, schedulers
and lyrics plugins that could never supply an image.

Measured on a copy of a production library: 'artwork explain' created
apple-music/kvstore.db, nd-lyrics/kvstore.db and listenbrainz-daily-playlist/taskqueue.db.
The last one matters most — CreateQueue resets rows with status='running' to 'pending',
which against a live server sets up its in-flight tasks to run twice.

LoadPlugins now takes the names to load, and the artwork CLI passes the Agents list: a
plugin that is not a configured agent can never win, so there is nothing to gain by
instantiating it. The same two runs now create only apple-music, which is a configured
agent and therefore the cost of answering the question.

Init is gated separately on the caller's intent rather than on read-only. 'explain --live'
already means 'reach the provider', so it runs init; plain 'explain' and 'reprocess'
promise no external requests and must not. Documented in the --live flag help.

Found by Codex on 28eb39ac4.

* perf(cli): load plugin agents only when the selection can consult one

Explaining disc or media file artwork loaded every configured metadata plugin, though
neither resolver ever reaches an agent: resolveMediaFile is embedded-only and
discArtworkReader.selectImage refuses external outright. With --live that also ran plugin
init for a walk that provably cannot reach the network. The load now sits inside the
artist/album branch that already exists, so it is a move rather than a new condition.

reprocess did the same for a radio-only selection, whose estimate is unconditionally zero.
It is gated on needsImageAgents, which asks exactly what ExternalLookupsPerItem asks, so
the two cannot disagree. An artist/album test would look equivalent and would silently
zero the playlist estimate, whose generated grid resolves album art through those agents;
a test pins that, and reverting the predicate to a whitelist fails it.

Found by Codex on b78e67b07.

* docs(artwork): trim the comments this branch added to the project budget

Six blocks ran past the one-to-two line limit. The LoadPlugins doc was eleven lines over
three paragraphs, needsImageAgents spent two of its four explaining an alternative that was
rejected, and inspectOpts restated what LoadPlugins already says.

What went is reviewer-facing prose that belongs in a commit message: the enumeration of
what Start does that this skips, and why an artist/album predicate would have been wrong.
What stayed is the reasoning a future reader needs at that line, notably that instantiating
a plugin creates its declared services, and that playlists consume the album agent count.

* docs(artwork): correct the breaker comment after the recovery ramp

It still said a success re-closes the breaker, which stopped being true when closing
started requiring breakerRecoveries consecutive answers.

* refactor(plugins): rename the scoped-load options to transientLoad

inspect claimed the load was only looking, which is false when runInit is true: it
instantiates the plugin and runs its init, which may open sockets. transient is accurate
for every use of the field, and explains all three behaviours it gates. A load that will
not outlive the command has no business persisting findings, instantiating plugins it will
never consult, or starting background work it is about to tear down.
2026-08-15 11:01:36 -04:00

680 lines
20 KiB
Go

package plugins
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"time"
"github.com/Masterminds/squirrel"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/rjeczalik/notify"
"github.com/tetratelabs/wazero"
)
const (
// defaultTimeout is the default timeout for plugin function calls
defaultTimeout = 30 * time.Second
// maxPluginLoadConcurrency is the maximum number of plugins that can be
// compiled/loaded in parallel during startup
maxPluginLoadConcurrency = 3
)
// SubsonicRouter is an http.Handler that serves Subsonic API requests.
type SubsonicRouter = http.Handler
// PluginMetricsRecorder is an interface for recording plugin metrics.
// This is satisfied by core/metrics.Metrics but defined here to avoid import cycles.
type PluginMetricsRecorder interface {
RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64)
}
// Manager manages loading and lifecycle of WebAssembly plugins.
// It implements both agents.PluginLoader and scrobbler.PluginLoader interfaces.
type Manager struct {
mu sync.RWMutex
plugins map[string]*plugin
ctx context.Context
cancel context.CancelFunc
cache wazero.CompilationCache
stopped atomic.Bool // Set to true when Stop() is called
loadWg sync.WaitGroup // Tracks in-flight plugin load operations
// File watcher fields (used when AutoReload is enabled)
watcherEvents chan notify.EventInfo
watcherDone chan struct{}
debounceTimers map[string]*time.Timer
debounceMu sync.Mutex
// transient is set by LoadPlugins, and nil for a server Start.
transient *transientLoad
// SubsonicAPI host function dependencies (set once before Start, not modified after)
subsonicRouter SubsonicRouter
ds model.DataStore
broker events.Broker
metrics PluginMetricsRecorder
}
// GetManager returns a singleton instance of the plugin manager.
// The manager is not started automatically; call Start() to begin loading plugins.
func GetManager(ds model.DataStore, broker events.Broker, m PluginMetricsRecorder) *Manager {
return singleton.GetInstance(func() *Manager {
return &Manager{
ds: ds,
broker: broker,
metrics: m,
plugins: make(map[string]*plugin),
}
})
}
// sendPluginRefreshEvent broadcasts a refresh event for the plugin resource.
// This notifies connected UI clients that plugin data has changed.
func (m *Manager) sendPluginRefreshEvent(ctx context.Context, pluginIDs ...string) {
if m.broker == nil {
return
}
event := (&events.RefreshResource{}).With("plugin", pluginIDs...)
m.broker.SendBroadcastMessage(ctx, event)
}
// SetSubsonicRouter sets the Subsonic router for SubsonicAPI host functions.
// This should be called after the subsonic router is created but before plugins
// that require SubsonicAPI access are loaded.
func (m *Manager) SetSubsonicRouter(router SubsonicRouter) {
m.subsonicRouter = router
}
// Start initializes the plugin manager and loads plugins from the configured folder.
// It should be called once during application startup when plugins are enabled.
// The startup flow is:
// 1. Sync plugins folder with DB (discover new, update changed, remove deleted)
// 2. Load only enabled plugins from DB
func (m *Manager) Start(ctx context.Context) error {
if !conf.Server.Plugins.Enabled {
log.Debug(ctx, "Plugin system is disabled")
return nil
}
if m.subsonicRouter == nil {
log.Fatal(ctx, "Plugin manager requires the SubsonicAPI router to be configured")
}
cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize)
if err := m.initRuntime(ctx, cacheDir); err != nil {
return err
}
if conf.Server.Plugins.Folder.String() == "" {
log.Debug(ctx, "No plugins folder configured")
return nil
}
folder := conf.Server.Plugins.Folder.MustPath()
log.Info(ctx, "Starting plugin manager", "folder", folder)
// Clear previous error states so plugins can be retried on restart
adminCtx := adminContext(ctx)
if err := m.ds.Plugin(adminCtx).ClearErrors(); err != nil {
log.Error(ctx, "Error clearing plugin errors", err)
}
// Sync plugins folder with DB
if err := m.syncPlugins(ctx, folder); err != nil {
log.Error(ctx, "Error syncing plugins with DB", err)
// Continue - we can still try to load plugins
}
// Load enabled plugins from DB
if err := m.loadEnabledPlugins(ctx); err != nil {
log.Error(ctx, "Error loading enabled plugins", err)
return fmt.Errorf("loading enabled plugins: %w", err)
}
// Start file watcher if auto-reload is enabled
if conf.Server.Plugins.AutoReload {
if err := m.startWatcher(); err != nil {
log.Error(ctx, "Failed to start plugin file watcher", err)
// Non-fatal - plugins are still loaded, just no auto-reload
}
}
return nil
}
// initRuntime prepares the extism/wazero runtime that instantiating a plugin needs.
func (m *Manager) initRuntime(ctx context.Context, cacheDir string) error {
pluginLogLevel := conf.Server.Plugins.LogLevel
if pluginLogLevel == "" {
pluginLogLevel = conf.Server.LogLevel
}
extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel)))
m.ctx, m.cancel = context.WithCancel(ctx)
var err error
m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
if err != nil {
log.Error(ctx, "Failed to create wazero compilation cache", err)
return fmt.Errorf("creating wazero compilation cache: %w", err)
}
return nil
}
// transientLoad scopes a load that will not outlive the command asking for it; see LoadPlugins.
type transientLoad struct {
only []string
runInit bool
}
// LoadPlugins loads the plugins named in only, so a CLI sees the agents a server would. Each is
// instantiated, creating any KVStore, TaskQueue or Storage it declares; call Stop when done.
func (m *Manager) LoadPlugins(ctx context.Context, only []string, runInit bool) error {
if !conf.Server.Plugins.Enabled || conf.Server.Plugins.Folder.String() == "" || len(only) == 0 {
return nil
}
m.transient = &transientLoad{only: only, runInit: runInit}
cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
if err := m.initRuntime(ctx, cacheDir); err != nil {
return err
}
if err := m.loadEnabledPlugins(ctx); err != nil {
return fmt.Errorf("loading enabled plugins: %w", err)
}
return nil
}
// Stop shuts down the plugin manager and releases all resources.
func (m *Manager) Stop() error {
// Mark as stopped first to prevent new operations
m.stopped.Store(true)
// Cancel context to signal all goroutines to stop
if m.cancel != nil {
m.cancel()
}
// Stop file watcher
m.stopWatcher()
// Wait for all in-flight plugin load operations to complete
// This is critical to avoid races with cache.Close()
m.loadWg.Wait()
m.mu.Lock()
defer m.mu.Unlock()
// Close all plugins
for name, plugin := range m.plugins {
err := plugin.Close()
if err != nil {
log.Error("Error during plugin cleanup", "plugin", name, err)
}
if plugin.compiled != nil {
if err := plugin.compiled.Close(context.Background()); err != nil {
log.Error("Error closing plugin", "plugin", name, err)
}
}
}
m.plugins = make(map[string]*plugin)
// Close compilation cache
if m.cache != nil {
if err := m.cache.Close(context.Background()); err != nil {
log.Error("Error closing wazero cache", err)
}
m.cache = nil
}
return nil
}
// PluginNames returns the names of all plugins that implement a particular capability.
// This is used by both agents and scrobbler systems to discover available plugins.
// Capabilities are auto-detected from the plugin's exported functions.
func (m *Manager) PluginNames(capability string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
var names []string
cap := Capability(capability)
for name, plugin := range m.plugins {
if hasCapability(plugin.capabilities, cap) {
names = append(names, name)
}
}
return names
}
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent)
}
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin)
}
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin)
}
func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin)
}
func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) {
m.mu.RLock()
p, ok := m.plugins[name]
m.mu.RUnlock()
var zero T
if !ok || !hasCapability(p.capabilities, cap) {
return zero, false
}
return newAdapter(p), true
}
// PluginInfo contains basic information about a plugin for metrics/insights.
type PluginInfo struct {
Name string
Version string
}
// GetPluginInfo returns information about all loaded plugins.
func (m *Manager) GetPluginInfo() map[string]PluginInfo {
m.mu.RLock()
defer m.mu.RUnlock()
info := make(map[string]PluginInfo, len(m.plugins))
for name, plugin := range m.plugins {
info[name] = PluginInfo{
Name: plugin.manifest.Name,
Version: plugin.manifest.Version,
}
}
return info
}
// EnablePlugin enables a plugin by loading it and updating the DB.
// Returns an error if the plugin is not found in DB or fails to load.
func (m *Manager) EnablePlugin(ctx context.Context, id string) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugin, err := repo.Get(id)
if err != nil {
return fmt.Errorf("getting plugin from DB: %w", err)
}
if plugin.Enabled {
return nil // Already enabled
}
// Check permission gates before enabling
if err := m.checkPermissionGates(plugin); err != nil {
return err
}
// Try to load the plugin
if err := m.loadPluginWithConfig(plugin); err != nil {
// Store error and return
plugin.LastError = err.Error()
plugin.UpdatedAt = time.Now()
_ = repo.Put(plugin)
return fmt.Errorf("loading plugin: %w", err)
}
// Update DB
plugin.Enabled = true
plugin.LastError = ""
plugin.UpdatedAt = time.Now()
if err := repo.Put(plugin); err != nil {
// Unload since we couldn't update DB
_ = m.unloadPlugin(id)
return fmt.Errorf("updating plugin in DB: %w", err)
}
log.Info(ctx, "Enabled plugin", "plugin", id)
m.sendPluginRefreshEvent(ctx, id)
return nil
}
// DisablePlugin disables a plugin by unloading it and updating the DB.
// Returns an error if the plugin is not found in DB.
func (m *Manager) DisablePlugin(ctx context.Context, id string) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugin, err := repo.Get(id)
if err != nil {
return fmt.Errorf("getting plugin from DB: %w", err)
}
if !plugin.Enabled {
return nil // Already disabled
}
// Unload the plugin
if err := m.unloadPlugin(id); err != nil {
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
}
// Update DB
plugin.Enabled = false
plugin.UpdatedAt = time.Now()
if err := repo.Put(plugin); err != nil {
return fmt.Errorf("updating plugin in DB: %w", err)
}
log.Info(ctx, "Disabled plugin", "plugin", id)
m.sendPluginRefreshEvent(ctx, id)
return nil
}
// ValidatePluginConfig validates a config JSON string against the plugin's config schema.
// If the plugin has no config schema defined, it returns an error.
// Returns nil if validation passes, or an error describing the validation failure.
func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugin, err := repo.Get(id)
if err != nil {
return fmt.Errorf("getting plugin from DB: %w", err)
}
manifest, err := ReadManifest(plugin.Path)
if err != nil {
return fmt.Errorf("reading manifest: %w", err)
}
return ValidateConfig(manifest, configJSON)
}
// UpdatePluginConfig updates the configuration for a plugin.
// If the plugin is enabled, it will be reloaded with the new config.
func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error {
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
p.Config = configJSON
})
}
// UpdatePluginUsers updates the users permission settings for a plugin.
// If the plugin is enabled, it will be reloaded with the new settings.
// If the plugin requires users permission and no users are configured (and allUsers is false),
// the plugin will be automatically disabled.
func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error {
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
p.Users = usersJSON
p.AllUsers = allUsers
})
}
// UpdatePluginLibraries updates the libraries permission settings for a plugin.
// If the plugin is enabled, it will be reloaded with the new settings.
// If the plugin requires library permission and no libraries are configured (and allLibraries is false),
// the plugin will be automatically disabled.
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
p.Libraries = librariesJSON
p.AllLibraries = allLibraries
p.AllowWriteAccess = allowWriteAccess
})
}
// RescanPlugins triggers a manual rescan of the plugins folder.
// This synchronizes the database with the filesystem, discovering new plugins,
// updating changed ones, and removing deleted ones.
func (m *Manager) RescanPlugins(ctx context.Context) error {
folder := conf.Server.Plugins.Folder.String()
if folder == "" {
return fmt.Errorf("plugins folder not configured")
}
log.Info(ctx, "Manual plugin rescan requested", "folder", folder)
return m.syncPlugins(ctx, folder)
}
// updatePluginSettings is a common implementation for updating plugin settings.
// The updateFn is called to apply the specific field updates to the plugin.
// If the plugin is enabled, it will be reloaded. If users permission is required
// but no longer satisfied, the plugin will be disabled.
func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn func(*model.Plugin)) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugin, err := repo.Get(id)
if err != nil {
return fmt.Errorf("getting plugin from DB: %w", err)
}
wasEnabled := plugin.Enabled
// Apply the specific updates
updateFn(plugin)
plugin.UpdatedAt = time.Now()
// Check if plugin requires permission and if it's still satisfied
shouldDisable := false
disableReason := ""
if wasEnabled {
manifest, err := ReadManifest(plugin.Path)
if err == nil && manifest.Permissions != nil {
if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) {
shouldDisable = true
disableReason = "users permission removal"
}
if manifest.Permissions.Library != nil && !hasValidLibrariesConfig(plugin.Libraries, plugin.AllLibraries) {
shouldDisable = true
disableReason = "library permission removal"
}
}
}
if shouldDisable {
// Disable the plugin since permission is no longer satisfied
if err := m.unloadPlugin(id); err != nil {
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
}
plugin.Enabled = false
if err := repo.Put(plugin); err != nil {
return fmt.Errorf("updating plugin in DB: %w", err)
}
log.Info(ctx, "Disabled plugin due to "+disableReason, "plugin", id)
m.sendPluginRefreshEvent(ctx, id)
return nil
}
if err := repo.Put(plugin); err != nil {
return fmt.Errorf("updating plugin in DB: %w", err)
}
// Reload if enabled
if wasEnabled {
if err := m.unloadPlugin(id); err != nil {
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
}
if err := m.loadPluginWithConfig(plugin); err != nil {
plugin.LastError = err.Error()
plugin.Enabled = false
_ = repo.Put(plugin)
return fmt.Errorf("reloading plugin: %w", err)
}
}
log.Info(ctx, "Updated plugin settings", "plugin", id)
m.sendPluginRefreshEvent(ctx, id)
return nil
}
// unloadPlugin removes a plugin from the manager and closes its resources.
// Returns an error if the plugin is not found.
func (m *Manager) unloadPlugin(name string) error {
m.mu.Lock()
plugin, ok := m.plugins[name]
if !ok {
m.mu.Unlock()
return fmt.Errorf("plugin %q not found", name)
}
delete(m.plugins, name)
m.mu.Unlock()
// Run cleanup functions
err := plugin.Close()
if err != nil {
log.Error("Error during plugin cleanup", "plugin", name, err)
}
// Close the compiled plugin outside the lock with a grace period
// to allow in-flight requests to complete
if plugin.compiled != nil {
// Use a brief timeout for cleanup
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := plugin.compiled.Close(ctx); err != nil {
log.Error("Error closing plugin during unload", "plugin", name, err)
}
}
runtime.GC()
log.Info(m.ctx, "Unloaded plugin", "plugin", name)
return nil
}
// UnloadDisabledPlugins checks for plugins that are disabled in the database
// but still loaded in memory, and unloads them. This is called after user or
// library deletion to clean up plugins that were auto-disabled due to
// permission loss.
func (m *Manager) UnloadDisabledPlugins(ctx context.Context) {
if m.ds == nil {
return
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
// Get all disabled plugins from the database
plugins, err := repo.GetAll(model.QueryOptions{
Filters: squirrel.Eq{"enabled": false},
})
if err != nil {
log.Error(ctx, "Failed to get disabled plugins", err)
return
}
// Check each disabled plugin and unload if still in memory
var unloaded []string
for _, p := range plugins {
m.mu.RLock()
_, loaded := m.plugins[p.ID]
m.mu.RUnlock()
if loaded {
if err := m.unloadPlugin(p.ID); err != nil {
log.Warn(ctx, "Failed to unload disabled plugin", "plugin", p.ID, err)
} else {
unloaded = append(unloaded, p.ID)
log.Info(ctx, "Unloaded disabled plugin", "plugin", p.ID)
}
}
}
// Send refresh events for unloaded plugins
if len(unloaded) > 0 {
m.sendPluginRefreshEvent(ctx, unloaded...)
}
}
// checkPermissionGates validates that all permission-based requirements are met
// before a plugin can be enabled. Returns an error if any gate condition fails.
func (m *Manager) checkPermissionGates(p *model.Plugin) error {
// Parse manifest to check permissions
manifest, err := ReadManifest(p.Path)
if err != nil {
return fmt.Errorf("reading manifest: %w", err)
}
// Check users permission gate
if manifest.Permissions != nil && manifest.Permissions.Users != nil {
if !hasValidUsersConfig(p.Users, p.AllUsers) {
return fmt.Errorf("users permission requires configuration: select users or enable 'all users' access")
}
}
// Check library permission gate
if manifest.Permissions != nil && manifest.Permissions.Library != nil {
if !hasValidLibrariesConfig(p.Libraries, p.AllLibraries) {
return fmt.Errorf("library permission requires configuration: select libraries or enable 'all libraries' access")
}
}
return nil
}
// hasValidUsersConfig checks if a plugin has valid users configuration.
// Returns true if allUsers is true, or if usersJSON contains at least one user.
func hasValidUsersConfig(usersJSON string, allUsers bool) bool {
if allUsers {
return true
}
if usersJSON == "" {
return false
}
var users []string
if err := json.Unmarshal([]byte(usersJSON), &users); err != nil {
return false
}
return len(users) > 0
}
// hasValidLibrariesConfig checks if a plugin has valid libraries configuration.
// Returns true if allLibraries is true, or if librariesJSON contains at least one library.
func hasValidLibrariesConfig(librariesJSON string, allLibraries bool) bool {
if allLibraries {
return true
}
if librariesJSON == "" {
return false
}
var libraries []int
if err := json.Unmarshal([]byte(librariesJSON), &libraries); err != nil {
return false
}
return len(libraries) > 0
}