package vip.mate.plugin; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import vip.mate.channel.ChannelManager; import vip.mate.llm.service.ModelProviderService; import vip.mate.memory.spi.MemoryManager; import vip.mate.plugin.api.MateClawPlugin; import vip.mate.plugin.api.PluginException; import vip.mate.plugin.api.PluginManifest; import vip.mate.plugin.model.PluginEntity; import vip.mate.plugin.model.PluginInfo; import vip.mate.plugin.repository.PluginMapper; import vip.mate.tool.ToolRegistry; import vip.mate.workspace.core.model.WorkspaceEntity; import vip.mate.workspace.core.service.WorkspaceService; import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.*; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.jar.JarFile; import java.util.stream.Stream; import java.util.zip.ZipEntry; /** * Plugin lifecycle manager. *

* Discovers, validates, loads, and manages plugins from JAR files. * Supports three discovery paths with priority: workspace > user-global > classpath. * Plugins are loaded on application startup and can be enabled/disabled at runtime. * * @author MateClaw Team */ @Slf4j @Component @RequiredArgsConstructor public class PluginManager { /** Current platform version for minPlatformVersion compatibility check */ private static final String PLATFORM_VERSION = "1.1.0"; private final PluginProperties pluginProperties; private final PluginMapper pluginMapper; private final ToolRegistry toolRegistry; private final ChannelManager channelManager; private final MemoryManager memoryManager; private final ModelProviderService modelProviderService; private final Optional workspaceService; private final Map plugins = new ConcurrentHashMap<>(); private final ObjectMapper objectMapper = new ObjectMapper(); /** * Load all plugins on application startup. * Scans three paths in priority order: workspace > user-global. * Higher priority plugins shadow lower priority ones with the same name. */ @EventListener(ApplicationReadyEvent.class) @Order(250) public void loadAllPlugins() { if (!pluginProperties.isEnabled()) { log.info("Plugin system is disabled"); return; } // Collect plugin JARs from all discovery paths in priority order List discoveredJars = discoverPluginJars(); int loaded = 0; int skipped = 0; int failed = 0; for (Path jar : discoveredJars) { try { // Read manifest to check name before loading PluginManifest manifest = readManifest(jar); if (plugins.containsKey(manifest.getName())) { log.info("Plugin {} already loaded from higher-priority path, skipping: {}", manifest.getName(), jar); skipped++; continue; } loadPlugin(jar); loaded++; } catch (Exception e) { failed++; log.error("Failed to load plugin from {}: {}", jar.getFileName(), e.getMessage(), e); recordError(jar, e); } } log.info("Plugin loading complete: {} loaded, {} skipped (duplicate), {} failed", loaded, skipped, failed); } /** * Discover plugin JARs from all paths in priority order. * Workspace plugins (highest priority) come first, then user-global. */ private List discoverPluginJars() { List jars = new ArrayList<>(); // 1. Workspace-level plugins (highest priority) workspaceService.ifPresent(ws -> { try { WorkspaceEntity defaultWs = ws.getBySlug(WorkspaceService.DEFAULT_SLUG); if (defaultWs != null && defaultWs.getBasePath() != null) { Path workspacePluginDir = Paths.get(defaultWs.getBasePath(), "plugins"); if (Files.isDirectory(workspacePluginDir)) { scanJars(workspacePluginDir, jars, "workspace"); } } } catch (Exception e) { log.debug("Workspace plugin scan skipped: {}", e.getMessage()); } }); // 2. User-global plugins Path userDir = Paths.get(pluginProperties.getUserDir()); try { Files.createDirectories(userDir); scanJars(userDir, jars, "user-global"); } catch (IOException e) { log.warn("Failed to scan user plugin directory {}: {}", userDir, e.getMessage()); } log.info("Discovered {} plugin JAR(s) across all paths", jars.size()); return jars; } private void scanJars(Path dir, List target, String source) { try (Stream stream = Files.list(dir)) { List found = stream .filter(p -> p.toString().endsWith(".jar")) .toList(); if (!found.isEmpty()) { log.info("Found {} plugin JAR(s) in {} ({})", found.size(), dir, source); target.addAll(found); } } catch (IOException e) { log.warn("Failed to list {} plugin directory {}: {}", source, dir, e.getMessage()); } } /** * Load a single plugin from a JAR file. * Ensures ClassLoader cleanup on any failure, and rolls back registrations * if onLoad/onEnable throws. */ public void loadPlugin(Path jarPath) throws Exception { log.info("Loading plugin from: {}", jarPath.getFileName()); // 1. Read and validate manifest PluginManifest manifest = readManifest(jarPath); manifest.validate(); // Check platform version compatibility if (!manifest.isCompatibleWith(PLATFORM_VERSION)) { throw new PluginException("Plugin " + manifest.getName() + " requires platform version " + manifest.getMinPlatformVersion() + " but current is " + PLATFORM_VERSION); } String pluginName = manifest.getName(); // 2. Check if already loaded if (plugins.containsKey(pluginName)) { log.warn("Plugin {} already loaded, skipping: {}", pluginName, jarPath); return; } // 3. Check DB for disabled state PluginEntity existing = findByName(pluginName); if (existing != null && !Boolean.TRUE.equals(existing.getEnabled())) { log.info("Plugin {} is disabled in database, skipping", pluginName); upsertEntity(manifest, jarPath.toString(), "DISABLED", null); return; } // 4. Create ClassLoader — all subsequent failures must close it URLClassLoader classLoader = new URLClassLoader( new URL[]{jarPath.toUri().toURL()}, getClass().getClassLoader() ); try { // 5. Instantiate entrypoint Class entryClass = classLoader.loadClass(manifest.getEntrypoint()); if (!MateClawPlugin.class.isAssignableFrom(entryClass)) { throw new PluginException("Entrypoint class " + manifest.getEntrypoint() + " does not implement MateClawPlugin"); } MateClawPlugin plugin = (MateClawPlugin) entryClass.getDeclaredConstructor().newInstance(); // 6. Create context and loaded plugin String configJson = existing != null ? existing.getConfigJson() : null; LoadedPlugin loadedPlugin = new LoadedPlugin(manifest, plugin, classLoader); PluginContextImpl context = new PluginContextImpl( loadedPlugin, manifest, toolRegistry, channelManager, memoryManager, modelProviderService, configJson ); loadedPlugin.setContext(context); // 7. Call lifecycle methods — rollback registrations on failure try { plugin.onLoad(context); plugin.onEnable(); } catch (Exception e) { log.error("Plugin {} lifecycle failed, rolling back registrations: {}", pluginName, e.getMessage()); rollbackRegistrations(loadedPlugin); throw e; } // 8. Register successfully plugins.put(pluginName, loadedPlugin); upsertEntity(manifest, jarPath.toString(), "ENABLED", null); log.info("Plugin loaded: {} v{} (type={}, tools={}, channels={})", pluginName, manifest.getVersion(), manifest.getType(), loadedPlugin.getRegisteredTools().size(), loadedPlugin.getRegisteredChannels().size()); } catch (Exception e) { // ClassLoader cleanup on any failure try { classLoader.close(); } catch (IOException closeEx) { log.warn("Failed to close ClassLoader for {}: {}", jarPath.getFileName(), closeEx.getMessage()); } throw e; } } /** * Rollback all registrations made by a plugin during failed onLoad/onEnable. */ private void rollbackRegistrations(LoadedPlugin loaded) { for (String toolName : loaded.getRegisteredTools()) { try { toolRegistry.unregisterPluginTool(toolName); } catch (Exception e) { /* best effort */ } } if (!loaded.getRegisteredChannels().isEmpty()) { try { channelManager.unregisterPluginChannel(loaded.getManifest().getName()); } catch (Exception e) { /* best effort */ } } if (loaded.getRegisteredMemoryProvider() != null) { try { memoryManager.unregisterPluginProvider(loaded.getRegisteredMemoryProvider()); } catch (Exception e) { /* best effort */ } } if (loaded.getRegisteredProvider() != null) { try { modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider()); } catch (Exception e) { /* best effort */ } } } /** * Disable a plugin by name. */ public void disablePlugin(String name) { LoadedPlugin loaded = plugins.get(name); if (loaded == null) { throw new PluginException("Plugin not found or not running: " + name); } // Call onDisable try { loaded.getPlugin().onDisable(); } catch (Exception e) { log.warn("Plugin {} onDisable() threw exception: {}", name, e.getMessage()); } // Unregister all capabilities int toolsRemoved = 0; for (String toolName : loaded.getRegisteredTools()) { toolRegistry.unregisterPluginTool(toolName); toolsRemoved++; } channelManager.unregisterPluginChannel(name); int channelsRemoved = loaded.getRegisteredChannels().size(); String memoryRemoved = null; if (loaded.getRegisteredMemoryProvider() != null) { memoryManager.unregisterPluginProvider(loaded.getRegisteredMemoryProvider()); memoryRemoved = loaded.getRegisteredMemoryProvider(); } String providerRemoved = null; if (loaded.getRegisteredProvider() != null) { modelProviderService.unregisterPluginChatModel(loaded.getRegisteredProvider()); providerRemoved = loaded.getRegisteredProvider(); } loaded.setEnabled(false); plugins.remove(name); updateStatus(name, false, "DISABLED", null); log.info("Plugin disabled: {} (tools={}, channels={}, provider={}, memory={})", name, toolsRemoved, channelsRemoved, providerRemoved != null ? providerRemoved : "none", memoryRemoved != null ? memoryRemoved : "none"); } /** * Enable a previously disabled plugin. * Validates JAR still exists before re-loading. */ public void enablePlugin(String name) { PluginEntity entity = findByName(name); if (entity == null) { throw new PluginException("Plugin not found in database: " + name); } if (entity.getJarPath() == null) { throw new PluginException("Plugin JAR path unknown for: " + name); } // Validate JAR still exists on disk Path jarPath = Paths.get(entity.getJarPath()); if (!Files.exists(jarPath)) { updateStatus(name, false, "ERROR", "JAR file not found: " + entity.getJarPath()); throw new PluginException("Plugin JAR file no longer exists: " + entity.getJarPath()); } updateStatus(name, true, "LOADING", null); try { loadPlugin(jarPath); } catch (Exception e) { updateStatus(name, false, "ERROR", e.getMessage()); throw new PluginException("Failed to re-enable plugin " + name + ": " + e.getMessage(), e); } } /** * List all known plugins (loaded + DB-only). * Secret config values are redacted in the response. */ @SuppressWarnings("unchecked") public List listPlugins() { Map result = new LinkedHashMap<>(); // In-memory loaded plugins for (Map.Entry entry : plugins.entrySet()) { LoadedPlugin loaded = entry.getValue(); PluginManifest m = loaded.getManifest(); result.put(entry.getKey(), PluginInfo.builder() .name(m.getName()) .version(m.getVersion()) .type(m.getType()) .displayName(m.getDisplayName()) .description(m.getDescription()) .author(m.getAuthor()) .enabled(loaded.isEnabled()) .status("ENABLED") .registeredTools(List.copyOf(loaded.getRegisteredTools())) .registeredChannels(List.copyOf(loaded.getRegisteredChannels())) .registeredProvider(loaded.getRegisteredProvider()) .registeredMemoryProvider(loaded.getRegisteredMemoryProvider()) .configSchema(buildConfigSchema(m)) .currentConfig(buildRedactedConfig(loaded)) .build()); } // DB-only entries (disabled plugins not in memory) List dbPlugins = pluginMapper.selectList(new LambdaQueryWrapper<>()); for (PluginEntity entity : dbPlugins) { if (!result.containsKey(entity.getName())) { result.put(entity.getName(), PluginInfo.builder() .name(entity.getName()) .version(entity.getVersion()) .type(entity.getPluginType()) .displayName(entity.getDisplayName()) .description(entity.getDescription()) .author(entity.getAuthor()) .enabled(Boolean.TRUE.equals(entity.getEnabled())) .status(entity.getStatus()) .errorMessage(entity.getErrorMessage()) .jarPath(entity.getJarPath()) .registeredTools(List.of()) .registeredChannels(List.of()) .build()); } } return new ArrayList<>(result.values()); } /** * Get a single plugin's info by name. */ public PluginInfo getPlugin(String name) { return listPlugins().stream() .filter(p -> p.getName().equals(name)) .findFirst() .orElseThrow(() -> new PluginException("Plugin not found: " + name)); } /** * Update a plugin's configuration. */ public void updateConfig(String name, Map config) { PluginEntity entity = findByName(name); if (entity == null) { throw new PluginException("Plugin not found: " + name); } // Validate config keys against manifest if plugin is loaded LoadedPlugin loaded = plugins.get(name); if (loaded != null && loaded.getManifest().getConfig() != null) { Map schema = loaded.getManifest().getConfig(); for (String key : config.keySet()) { if (!schema.containsKey(key)) { log.warn("Plugin {} config: unknown key '{}' (not in manifest schema)", name, key); } } // Check required fields for (Map.Entry schemaEntry : schema.entrySet()) { if (schemaEntry.getValue().isRequired() && !config.containsKey(schemaEntry.getKey())) { throw new PluginException("Missing required config field: " + schemaEntry.getKey()); } } } try { entity.setConfigJson(objectMapper.writeValueAsString(config)); pluginMapper.updateById(entity); log.info("Plugin config updated: {}", name); } catch (PluginException e) { throw e; } catch (Exception e) { throw new PluginException("Failed to update config for " + name + ": " + e.getMessage(), e); } } // ==================== Config Schema & Redaction ==================== /** * Build config schema map from manifest for frontend display. */ private Map buildConfigSchema(PluginManifest manifest) { if (manifest.getConfig() == null || manifest.getConfig().isEmpty()) { return null; } Map schema = new LinkedHashMap<>(); manifest.getConfig().forEach((key, field) -> { Map fieldInfo = new LinkedHashMap<>(); fieldInfo.put("type", field.getType()); fieldInfo.put("required", field.isRequired()); fieldInfo.put("secret", field.isSecret()); fieldInfo.put("description", field.getDescription()); schema.put(key, fieldInfo); }); return schema; } /** * Build config map with secret values redacted. */ @SuppressWarnings("unchecked") private Map buildRedactedConfig(LoadedPlugin loaded) { PluginEntity entity = findByName(loaded.getManifest().getName()); if (entity == null || entity.getConfigJson() == null || entity.getConfigJson().isBlank()) { return null; } try { Map config = objectMapper.readValue(entity.getConfigJson(), Map.class); // Redact secret fields Map schema = loaded.getManifest().getConfig(); if (schema != null) { for (Map.Entry schemaEntry : schema.entrySet()) { if (schemaEntry.getValue().isSecret() && config.containsKey(schemaEntry.getKey())) { Object val = config.get(schemaEntry.getKey()); if (val != null && !val.toString().isBlank()) { config.put(schemaEntry.getKey(), "****"); } } } } return config; } catch (Exception e) { return null; } } // ==================== Internal Methods ==================== private PluginManifest readManifest(Path jarPath) throws IOException { try (JarFile jarFile = new JarFile(jarPath.toFile())) { ZipEntry entry = jarFile.getEntry("mateclaw-plugin.json"); if (entry == null) { throw new PluginException("No mateclaw-plugin.json found in " + jarPath); } try (InputStream is = jarFile.getInputStream(entry)) { return objectMapper.readValue(is, PluginManifest.class); } } } private PluginEntity findByName(String name) { return pluginMapper.selectOne(new LambdaQueryWrapper() .eq(PluginEntity::getName, name)); } private void upsertEntity(PluginManifest manifest, String jarPath, String status, String error) { PluginEntity existing = findByName(manifest.getName()); if (existing != null) { existing.setVersion(manifest.getVersion()); existing.setPluginType(manifest.getType()); existing.setDisplayName(manifest.getDisplayName()); existing.setDescription(manifest.getDescription()); existing.setAuthor(manifest.getAuthor()); existing.setEntrypoint(manifest.getEntrypoint()); existing.setJarPath(jarPath); existing.setStatus(status); existing.setErrorMessage(error); existing.setEnabled(!"DISABLED".equals(status) && !"ERROR".equals(status)); pluginMapper.updateById(existing); } else { PluginEntity entity = new PluginEntity(); entity.setName(manifest.getName()); entity.setVersion(manifest.getVersion()); entity.setPluginType(manifest.getType()); entity.setDisplayName(manifest.getDisplayName()); entity.setDescription(manifest.getDescription()); entity.setAuthor(manifest.getAuthor()); entity.setEntrypoint(manifest.getEntrypoint()); entity.setJarPath(jarPath); entity.setStatus(status); entity.setErrorMessage(error); entity.setEnabled(!"DISABLED".equals(status) && !"ERROR".equals(status)); entity.setCreateTime(LocalDateTime.now()); entity.setUpdateTime(LocalDateTime.now()); entity.setConfigJson("{}"); pluginMapper.insert(entity); } } private void updateStatus(String name, boolean enabled, String status, String error) { PluginEntity entity = findByName(name); if (entity != null) { entity.setEnabled(enabled); entity.setStatus(status); entity.setErrorMessage(error); pluginMapper.updateById(entity); } } private void recordError(Path jarPath, Exception e) { try { PluginManifest manifest = readManifest(jarPath); upsertEntity(manifest, jarPath.toString(), "ERROR", e.getMessage()); } catch (Exception ex) { log.warn("Cannot record plugin error (manifest unreadable): {} — {}", jarPath.getFileName(), ex.getMessage()); } } }