diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cf0892f935..94f8556d13 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -27,6 +27,7 @@ import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPref import fontList from 'font-list' import { agentMessageRepository } from './services/agents/database' +import { PluginService } from './services/agents/plugins/PluginService' import { apiServerService } from './services/ApiServerService' import appService from './services/AppService' import AppUpdater from './services/AppUpdater' @@ -47,7 +48,6 @@ import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ocrService } from './services/ocr/OcrService' import OvmsManager from './services/OvmsManager' -import { PluginService } from './services/PluginService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' import { FileServiceManager } from './services/remotefile/FileServiceManager' diff --git a/src/main/services/PluginService.ts b/src/main/services/PluginService.ts deleted file mode 100644 index 8ff1820208..0000000000 --- a/src/main/services/PluginService.ts +++ /dev/null @@ -1,1171 +0,0 @@ -import { loggerService } from '@logger' -import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' -import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' -import type { - AgentEntity, - InstalledPlugin, - InstallPluginOptions, - ListAvailablePluginsResult, - PluginError, - PluginMetadata, - PluginType, - UninstallPluginOptions -} from '@types' -import * as crypto from 'crypto' -import { app } from 'electron' -import * as fs from 'fs' -import * as path from 'path' - -import { AgentService } from './agents/services/AgentService' - -const logger = loggerService.withContext('PluginService') - -interface PluginServiceConfig { - maxFileSize: number // bytes - cacheTimeout: number // milliseconds -} - -/** - * PluginService manages agent and command plugins from resources directory. - * - * Features: - * - Singleton pattern for consistent state management - * - Caching of available plugins for performance - * - Security validation (path traversal, file size, extensions) - * - Transactional install/uninstall operations - * - Integration with AgentService for metadata persistence - */ -export class PluginService { - private static instance: PluginService | null = null - - private availablePluginsCache: ListAvailablePluginsResult | null = null - private cacheTimestamp = 0 - private config: PluginServiceConfig - - private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] - - private constructor(config?: Partial) { - this.config = { - maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default - cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default - } - - logger.info('PluginService initialized', { - maxFileSize: this.config.maxFileSize, - cacheTimeout: this.config.cacheTimeout - }) - } - - /** - * Get singleton instance - */ - static getInstance(config?: Partial): PluginService { - if (!PluginService.instance) { - PluginService.instance = new PluginService(config) - } - return PluginService.instance - } - - /** - * List all available plugins from resources directory (with caching) - */ - async listAvailable(): Promise { - const now = Date.now() - - // Return cached data if still valid - if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) { - logger.debug('Returning cached plugin list', { - cacheAge: now - this.cacheTimestamp - }) - return this.availablePluginsCache - } - - logger.info('Scanning available plugins') - - // Scan all plugin types - const [agents, commands, skills] = await Promise.all([ - this.scanPluginDirectory('agent'), - this.scanPluginDirectory('command'), - this.scanSkillDirectory() - ]) - - const result: ListAvailablePluginsResult = { - agents, - commands, - skills, // NEW: include skills - total: agents.length + commands.length + skills.length - } - - // Update cache - this.availablePluginsCache = result - this.cacheTimestamp = now - - logger.info('Available plugins scanned', { - agentsCount: agents.length, - commandsCount: commands.length, - skillsCount: skills.length, - total: result.total - }) - - return result - } - - /** - * Install plugin with validation and transactional safety - */ - async install(options: InstallPluginOptions): Promise { - logger.info('Installing plugin', options) - - // Validate source path - this.validateSourcePath(options.sourcePath) - - // Get agent and validate - const agent = await AgentService.getInstance().getAgent(options.agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - await this.validateWorkdir(workdir, options.agentId) - - // Get absolute source path - const basePath = this.getPluginsBasePath() - const sourceAbsolutePath = path.join(basePath, options.sourcePath) - - // BRANCH: Handle skills differently than files - if (options.type === 'skill') { - // Validate skill folder exists and is a directory - try { - const stats = await fs.promises.stat(sourceAbsolutePath) - if (!stats.isDirectory()) { - throw { - type: 'INVALID_METADATA', - reason: 'Skill source is not a directory', - path: options.sourcePath - } as PluginError - } - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: sourceAbsolutePath - } as PluginError - } - - // Parse metadata from SKILL.md - const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') - - // Sanitize folder name (different rules than file names) - const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) - - // Ensure .claude/skills directory exists - await this.ensureClaudeDirectory(workdir, 'skill') - - // Construct destination path (folder, not file) - const destPath = path.join(workdir, '.claude', 'skills', sanitizedFolderName) - - // Update metadata with sanitized folder name - metadata.filename = sanitizedFolderName - - // Execute skill-specific install - await this.installSkill(agent, sourceAbsolutePath, destPath, metadata) - - logger.info('Skill installed successfully', { - agentId: options.agentId, - sourcePath: options.sourcePath, - folderName: sanitizedFolderName - }) - - return { - ...metadata, - installedAt: Date.now() - } - } - - // EXISTING LOGIC for agents/commands (unchanged) - // Files go through existing validation and sanitization - await this.validatePluginFile(sourceAbsolutePath) - - // Parse metadata - const category = path.basename(path.dirname(options.sourcePath)) - const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, options.type) - - // Sanitize filename - const sanitizedFilename = this.sanitizeFilename(metadata.filename) - - // Ensure .claude directory exists - await this.ensureClaudeDirectory(workdir, options.type) - - // Get destination path - const destDir = path.join(workdir, '.claude', options.type === 'agent' ? 'agents' : 'commands') - const destPath = path.join(destDir, sanitizedFilename) - - // Check for duplicate and auto-uninstall if exists - const existingPlugins = agent.configuration?.installed_plugins || [] - const existingPlugin = existingPlugins.find((p) => p.filename === sanitizedFilename && p.type === options.type) - - if (existingPlugin) { - logger.info('Plugin already installed, auto-uninstalling old version', { - filename: sanitizedFilename - }) - await this.uninstallTransaction(agent, sanitizedFilename, options.type) - - // Re-fetch agent after uninstall - const updatedAgent = await AgentService.getInstance().getAgent(options.agentId) - if (!updatedAgent) { - throw { - type: 'TRANSACTION_FAILED', - operation: 'install', - reason: 'Agent not found after uninstall' - } as PluginError - } - - await this.installTransaction(updatedAgent, sourceAbsolutePath, destPath, metadata) - } else { - await this.installTransaction(agent, sourceAbsolutePath, destPath, metadata) - } - - logger.info('Plugin installed successfully', { - agentId: options.agentId, - filename: sanitizedFilename, - type: options.type - }) - - return { - ...metadata, - filename: sanitizedFilename, - installedAt: Date.now() - } - } - - /** - * Uninstall plugin with cleanup - */ - async uninstall(options: UninstallPluginOptions): Promise { - logger.info('Uninstalling plugin', options) - - // Get agent - const agent = await AgentService.getInstance().getAgent(options.agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - // BRANCH: Handle skills differently than files - if (options.type === 'skill') { - // For skills, filename is the folder name (no extension) - // Use sanitizeFolderName to ensure consistency - const sanitizedFolderName = this.sanitizeFolderName(options.filename) - await this.uninstallSkill(agent, sanitizedFolderName) - - logger.info('Skill uninstalled successfully', { - agentId: options.agentId, - folderName: sanitizedFolderName - }) - - return - } - - // EXISTING LOGIC for agents/commands (unchanged) - // For files, filename includes .md extension - const sanitizedFilename = this.sanitizeFilename(options.filename) - await this.uninstallTransaction(agent, sanitizedFilename, options.type) - - logger.info('Plugin uninstalled successfully', { - agentId: options.agentId, - filename: sanitizedFilename, - type: options.type - }) - } - - /** - * List installed plugins for an agent (from database + filesystem validation) - */ - async listInstalled(agentId: string): Promise { - logger.debug('Listing installed plugins', { agentId }) - - // Get agent - const agent = await AgentService.getInstance().getAgent(agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const installedPlugins = agent.configuration?.installed_plugins || [] - const workdir = agent.accessible_paths?.[0] - - if (!workdir) { - logger.warn('Agent has no accessible paths', { agentId }) - return [] - } - - // Validate each plugin still exists on filesystem - const validatedPlugins: InstalledPlugin[] = [] - - for (const plugin of installedPlugins) { - // Get plugin path based on type - let pluginPath: string - if (plugin.type === 'skill') { - pluginPath = path.join(workdir, '.claude', 'skills', plugin.filename) - } else { - pluginPath = path.join(workdir, '.claude', plugin.type === 'agent' ? 'agents' : 'commands', plugin.filename) - } - - try { - const stats = await fs.promises.stat(pluginPath) - - // For files (agents/commands), verify file hash if stored - if (plugin.type !== 'skill' && plugin.contentHash) { - const currentHash = await this.calculateFileHash(pluginPath) - if (currentHash !== plugin.contentHash) { - logger.warn('Plugin file hash mismatch', { - filename: plugin.filename, - expected: plugin.contentHash, - actual: currentHash - }) - } - } - - // For skills, stats.size is folder size (handled differently) - // For files, stats.size is file size - validatedPlugins.push({ - filename: plugin.filename, - type: plugin.type, - metadata: { - sourcePath: plugin.sourcePath, - filename: plugin.filename, - name: plugin.name, - description: plugin.description, - allowed_tools: plugin.allowed_tools, - tools: plugin.tools, - category: plugin.category || '', - type: plugin.type, - tags: plugin.tags, - version: plugin.version, - author: plugin.author, - size: stats.size, - contentHash: plugin.contentHash, - installedAt: plugin.installedAt, - updatedAt: plugin.updatedAt - } - }) - } catch (error) { - logger.warn('Plugin not found on filesystem', { - filename: plugin.filename, - path: pluginPath, - error: error instanceof Error ? error.message : String(error) - }) - } - } - - logger.debug('Listed installed plugins', { - agentId, - count: validatedPlugins.length - }) - - return validatedPlugins - } - - /** - * Invalidate plugin cache (for development/testing) - */ - invalidateCache(): void { - this.availablePluginsCache = null - this.cacheTimestamp = 0 - logger.info('Plugin cache invalidated') - } - - /** - * Read plugin content from source (resources directory) - */ - async readContent(sourcePath: string): Promise { - logger.info('Reading plugin content', { sourcePath }) - - // Validate source path - this.validateSourcePath(sourcePath) - - // Get absolute path - const basePath = this.getPluginsBasePath() - const absolutePath = path.join(basePath, sourcePath) - - // Validate file exists and is accessible - try { - await fs.promises.access(absolutePath, fs.constants.R_OK) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: sourcePath - } as PluginError - } - - // Read content - try { - const content = await fs.promises.readFile(absolutePath, 'utf8') - logger.debug('Plugin content read successfully', { - sourcePath, - size: content.length - }) - return content - } catch (error) { - throw { - type: 'READ_FAILED', - path: sourcePath, - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Write plugin content to installed plugin (in agent's .claude directory) - * Note: Only works for file-based plugins (agents/commands), not skills - */ - async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { - logger.info('Writing plugin content', { agentId, filename, type }) - - // Get agent - const agent = await AgentService.getInstance().getAgent(agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - // Check if plugin is installed - const installedPlugins = agent.configuration?.installed_plugins || [] - const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) - - if (!installedPlugin) { - throw { - type: 'PLUGIN_NOT_INSTALLED', - filename, - agentId - } as PluginError - } - - // Get file path - const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) - - // Verify file exists - try { - await fs.promises.access(filePath, fs.constants.W_OK) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: filePath - } as PluginError - } - - // Write content - try { - await fs.promises.writeFile(filePath, content, 'utf8') - logger.debug('Plugin content written successfully', { - filePath, - size: content.length - }) - - // Update content hash in database - const newContentHash = crypto.createHash('sha256').update(content).digest('hex') - const updatedPlugins = installedPlugins.map((p) => { - if (p.filename === filename && p.type === type) { - return { - ...p, - contentHash: newContentHash, - updatedAt: Date.now() - } - } - return p - }) - - await AgentService.getInstance().updateAgent(agentId, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logger.info('Plugin content updated successfully', { - agentId, - filename, - type, - newContentHash - }) - } catch (error) { - throw { - type: 'WRITE_FAILED', - path: filePath, - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - // ============================================================================ - // Private Helper Methods - // ============================================================================ - - /** - * Get absolute path to plugins directory (handles packaged vs dev) - */ - private getPluginsBasePath(): string { - // Use the utility function which handles both dev and production correctly - if (app.isPackaged) { - return path.join(process.resourcesPath, 'claude-code-plugins') - } - return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') - } - - /** - * Scan plugin directory and return metadata for all plugins - */ - private async scanPluginDirectory(type: 'agent' | 'command'): Promise { - const basePath = this.getPluginsBasePath() - const typeDir = path.join(basePath, type === 'agent' ? 'agents' : 'commands') - - try { - await fs.promises.access(typeDir, fs.constants.R_OK) - } catch (error) { - logger.warn(`Plugin directory not accessible: ${typeDir}`, { - error: error instanceof Error ? error.message : String(error) - }) - return [] - } - - const plugins: PluginMetadata[] = [] - const categories = await fs.promises.readdir(typeDir, { withFileTypes: true }) - - for (const categoryEntry of categories) { - if (!categoryEntry.isDirectory()) { - continue - } - - const category = categoryEntry.name - const categoryPath = path.join(typeDir, category) - const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) - - for (const file of files) { - if (!file.isFile()) { - continue - } - - const ext = path.extname(file.name).toLowerCase() - if (!this.ALLOWED_EXTENSIONS.includes(ext)) { - continue - } - - try { - const filePath = path.join(categoryPath, file.name) - const sourcePath = path.join(type === 'agent' ? 'agents' : 'commands', category, file.name) - - const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) - plugins.push(metadata) - } catch (error) { - logger.warn(`Failed to parse plugin: ${file.name}`, { - category, - error: error instanceof Error ? error.message : String(error) - }) - } - } - } - - return plugins - } - - /** - * Scan skills directory for skill folders (recursively) - */ - private async scanSkillDirectory(): Promise { - const basePath = this.getPluginsBasePath() - const skillsPath = path.join(basePath, 'skills') - - const skills: PluginMetadata[] = [] - - try { - // Check if skills directory exists - try { - await fs.promises.access(skillsPath) - } catch { - logger.warn('Skills directory not found', { skillsPath }) - return [] - } - - // Recursively find all directories containing SKILL.md - const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) - - logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) - - // Parse metadata for each skill directory - for (const { folderPath, sourcePath } of skillDirectories) { - try { - const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') - skills.push(metadata) - } catch (error) { - logger.warn(`Failed to parse skill folder: ${sourcePath}`, { - folderPath, - error: error instanceof Error ? error.message : String(error) - }) - // Continue with other skills - } - } - } catch (error) { - logger.error('Failed to scan skill directory', { skillsPath, error }) - // Return empty array on error - } - - return skills - } - - /** - * Validate source path to prevent path traversal attacks - */ - private validateSourcePath(sourcePath: string): void { - // Remove any path traversal attempts - const normalized = path.normalize(sourcePath) - - // Ensure no parent directory access - if (normalized.includes('..')) { - throw { - type: 'PATH_TRAVERSAL', - message: 'Path traversal detected', - path: sourcePath - } as PluginError - } - - // Ensure path is within plugins directory - const basePath = this.getPluginsBasePath() - const absolutePath = path.join(basePath, normalized) - const resolvedPath = path.resolve(absolutePath) - - if (!resolvedPath.startsWith(path.resolve(basePath))) { - throw { - type: 'PATH_TRAVERSAL', - message: 'Path outside plugins directory', - path: sourcePath - } as PluginError - } - } - - /** - * Validate workdir against agent's accessible paths - */ - private async validateWorkdir(workdir: string, agentId: string): Promise { - // Get agent from database - const agent = await AgentService.getInstance().getAgent(agentId) - - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - workdir, - agentId, - message: 'Agent not found' - } as PluginError - } - - // Verify workdir is in agent's accessible_paths - if (!agent.accessible_paths?.includes(workdir)) { - throw { - type: 'INVALID_WORKDIR', - workdir, - agentId, - message: 'Workdir not in agent accessible paths' - } as PluginError - } - - // Verify workdir exists and is accessible - try { - await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) - } catch (error) { - throw { - type: 'WORKDIR_NOT_FOUND', - workdir, - message: 'Workdir does not exist or is not accessible' - } as PluginError - } - } - - /** - * Sanitize filename to remove unsafe characters (for agents/commands) - */ - private sanitizeFilename(filename: string): string { - // Remove path separators - let sanitized = filename.replace(/[/\\]/g, '_') - // Remove null bytes using String method to avoid control-regex lint error - sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') - // Limit to safe characters (alphanumeric, dash, underscore, dot) - sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') - - // Ensure .md extension - if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { - sanitized += '.md' - } - - return sanitized - } - - /** - * Sanitize folder name for skills (different rules than file names) - * NO dots allowed to avoid confusion with file extensions - */ - private sanitizeFolderName(folderName: string): string { - // Remove path separators - let sanitized = folderName.replace(/[/\\]/g, '_') - // Remove null bytes using String method to avoid control-regex lint error - sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') - // Limit to safe characters (alphanumeric, dash, underscore) - // NOTE: No dots allowed to avoid confusion with file extensions - sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') - - // Validate no extension was provided - if (folderName.includes('.')) { - logger.warn('Skill folder name contained dots, sanitized', { - original: folderName, - sanitized - }) - } - - return sanitized - } - - /** - * Validate plugin file (size, extension, frontmatter) - */ - private async validatePluginFile(filePath: string): Promise { - // Check file exists - let stats: fs.Stats - try { - stats = await fs.promises.stat(filePath) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: filePath - } as PluginError - } - - // Check file size - if (stats.size > this.config.maxFileSize) { - throw { - type: 'FILE_TOO_LARGE', - size: stats.size, - max: this.config.maxFileSize - } as PluginError - } - - // Check file extension - const ext = path.extname(filePath).toLowerCase() - if (!this.ALLOWED_EXTENSIONS.includes(ext)) { - throw { - type: 'INVALID_FILE_TYPE', - extension: ext - } as PluginError - } - - // Validate frontmatter can be parsed safely - // This is handled by parsePluginMetadata which uses FAILSAFE_SCHEMA - try { - const category = path.basename(path.dirname(filePath)) - const sourcePath = path.relative(this.getPluginsBasePath(), filePath) - const type = sourcePath.startsWith('agents') ? 'agent' : 'command' - - await parsePluginMetadata(filePath, sourcePath, category, type) - } catch (error) { - throw { - type: 'INVALID_METADATA', - reason: 'Failed to parse frontmatter', - path: filePath - } as PluginError - } - } - - /** - * Calculate SHA-256 hash of file - */ - private async calculateFileHash(filePath: string): Promise { - const content = await fs.promises.readFile(filePath, 'utf8') - return crypto.createHash('sha256').update(content).digest('hex') - } - - /** - * Ensure .claude subdirectory exists for the given plugin type - */ - private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { - const claudeDir = path.join(workdir, '.claude') - - let subDir: string - if (type === 'agent') { - subDir = 'agents' - } else if (type === 'command') { - subDir = 'commands' - } else if (type === 'skill') { - subDir = 'skills' - } else { - throw new Error(`Unknown plugin type: ${type}`) - } - - const typeDir = path.join(claudeDir, subDir) - - try { - await fs.promises.mkdir(typeDir, { recursive: true }) - logger.debug('Ensured directory exists', { typeDir }) - } catch (error) { - logger.error('Failed to create directory', { - typeDir, - error: error instanceof Error ? error.message : String(error) - }) - throw { - type: 'PERMISSION_DENIED', - path: typeDir - } as PluginError - } - } - - /** - * Transactional install operation - * Steps: - * 1. Copy to temp location - * 2. Update database - * 3. Move to final location (atomic) - * Rollback on error - */ - private async installTransaction( - agent: AgentEntity, - sourceAbsolutePath: string, - destPath: string, - metadata: PluginMetadata - ): Promise { - const tempPath = `${destPath}.tmp` - let fileCopied = false - - try { - // Step 1: Copy file to temporary location - await fs.promises.copyFile(sourceAbsolutePath, tempPath) - fileCopied = true - logger.debug('File copied to temp location', { tempPath }) - - // Step 2: Update agent configuration in database - const existingPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = [ - ...existingPlugins, - { - sourcePath: metadata.sourcePath, - filename: metadata.filename, - type: metadata.type, - name: metadata.name, - description: metadata.description, - allowed_tools: metadata.allowed_tools, - tools: metadata.tools, - category: metadata.category, - tags: metadata.tags, - version: metadata.version, - author: metadata.author, - contentHash: metadata.contentHash, - installedAt: Date.now() - } - ] - - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logger.debug('Agent configuration updated', { agentId: agent.id }) - - // Step 3: Move temp file to final location (atomic on same filesystem) - await fs.promises.rename(tempPath, destPath) - logger.debug('File moved to final location', { destPath }) - } catch (error) { - // Rollback: delete temp file if it exists - if (fileCopied) { - try { - await fs.promises.unlink(tempPath) - logger.debug('Rolled back temp file', { tempPath }) - } catch (unlinkError) { - logger.error('Failed to rollback temp file', { - tempPath, - error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) - }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'install', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Transactional uninstall operation - * Steps: - * 1. Update database - * 2. Delete file - * Rollback database on error - */ - private async uninstallTransaction(agent: AgentEntity, filename: string, type: 'agent' | 'command'): Promise { - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: agent.id, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) - - // Step 1: Update database first (easier to rollback file operations) - const originalPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = originalPlugins.filter((p) => !(p.filename === filename && p.type === type)) - - let dbUpdated = false - - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - dbUpdated = true - logger.debug('Agent configuration updated', { agentId: agent.id }) - - // Step 2: Delete file - try { - await fs.promises.unlink(filePath) - logger.debug('Plugin file deleted', { filePath }) - } catch (error) { - const nodeError = error as NodeJS.ErrnoException - if (nodeError.code !== 'ENOENT') { - throw error // File should exist, re-throw if not ENOENT - } - logger.warn('Plugin file already deleted', { filePath }) - } - } catch (error) { - // Rollback: restore database if file deletion failed - if (dbUpdated) { - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: originalPlugins - } - }) - logger.debug('Rolled back database update', { agentId: agent.id }) - } catch (rollbackError) { - logger.error('Failed to rollback database', { - agentId: agent.id, - error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError) - }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'uninstall', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Install a skill (copy entire folder) - */ - private async installSkill( - agent: AgentEntity, - sourceAbsolutePath: string, - destPath: string, - metadata: PluginMetadata - ): Promise { - const logContext = logger.withContext('installSkill') - - // Step 1: If destination exists, remove it first (overwrite behavior) - try { - await fs.promises.access(destPath) - // Exists - remove it - await deleteDirectoryRecursive(destPath) - logContext.info('Removed existing skill folder', { destPath }) - } catch { - // Doesn't exist - nothing to remove - } - - // Step 2: Copy folder to temporary location - const tempPath = `${destPath}.tmp` - let folderCopied = false - - try { - // Copy to temp location - await copyDirectoryRecursive(sourceAbsolutePath, tempPath) - folderCopied = true - logContext.info('Skill folder copied to temp location', { tempPath }) - - // Step 3: Update agent configuration in database - const updatedPlugins = [ - ...(agent.configuration?.installed_plugins || []).filter( - (p) => !(p.filename === metadata.filename && p.type === 'skill') - ), - { - sourcePath: metadata.sourcePath, - filename: metadata.filename, // Folder name, no extension - type: metadata.type, - name: metadata.name, - description: metadata.description, - tools: metadata.tools, - category: metadata.category, - tags: metadata.tags, - version: metadata.version, - author: metadata.author, - contentHash: metadata.contentHash, - installedAt: Date.now() - } - ] - - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logContext.info('Agent configuration updated', { agentId: agent.id }) - - // Step 4: Move temp folder to final location (atomic on same filesystem) - await fs.promises.rename(tempPath, destPath) - logContext.info('Skill folder moved to final location', { destPath }) - } catch (error) { - // Rollback: delete temp folder if it exists - if (folderCopied) { - try { - await deleteDirectoryRecursive(tempPath) - logContext.info('Rolled back temp folder', { tempPath }) - } catch (unlinkError) { - logContext.error('Failed to rollback temp folder', { tempPath, error: unlinkError }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'install-skill', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Uninstall a skill (remove entire folder) - */ - private async uninstallSkill(agent: AgentEntity, folderName: string): Promise { - const logContext = logger.withContext('uninstallSkill') - const workdir = agent.accessible_paths?.[0] - - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: agent.id, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - const skillPath = path.join(workdir, '.claude', 'skills', folderName) - - // Step 1: Update database first - const originalPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = originalPlugins.filter((p) => !(p.filename === folderName && p.type === 'skill')) - - let dbUpdated = false - - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - dbUpdated = true - logContext.info('Agent configuration updated', { agentId: agent.id }) - - // Step 2: Delete folder - try { - await deleteDirectoryRecursive(skillPath) - logContext.info('Skill folder deleted', { skillPath }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error // Folder should exist, re-throw if not ENOENT - } - logContext.warn('Skill folder already deleted', { skillPath }) - } - } catch (error) { - // Rollback: restore database if folder deletion failed - if (dbUpdated) { - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: originalPlugins - } - }) - logContext.info('Rolled back database update', { agentId: agent.id }) - } catch (rollbackError) { - logContext.error('Failed to rollback database', { agentId: agent.id, error: rollbackError }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'uninstall-skill', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } -} - -export const pluginService = PluginService.getInstance() diff --git a/src/main/services/agents/plugins/PluginCacheStore.ts b/src/main/services/agents/plugins/PluginCacheStore.ts new file mode 100644 index 0000000000..77b427f4e8 --- /dev/null +++ b/src/main/services/agents/plugins/PluginCacheStore.ts @@ -0,0 +1,426 @@ +import { loggerService } from '@logger' +import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { CachedPluginsData, InstalledPlugin, PluginError, PluginMetadata, PluginType } from '@types' +import { CachedPluginsDataSchema } from '@types' +import * as fs from 'fs' +import * as path from 'path' + +const logger = loggerService.withContext('PluginCacheStore') + +interface PluginCacheStoreDeps { + allowedExtensions: string[] + getPluginDirectoryName: (type: PluginType) => 'agents' | 'commands' | 'skills' + getClaudeBasePath: (workdir: string) => string + getClaudePluginDirectory: (workdir: string, type: PluginType) => string + getPluginsBasePath: () => string +} + +export class PluginCacheStore { + constructor(private readonly deps: PluginCacheStoreDeps) {} + + async listAvailableFilePlugins(type: 'agent' | 'command'): Promise { + const basePath = this.deps.getPluginsBasePath() + const directory = path.join(basePath, this.deps.getPluginDirectoryName(type)) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch (error) { + logger.warn(`Plugin directory not accessible: ${directory}`, { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + + const plugins: PluginMetadata[] = [] + const categories = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const categoryEntry of categories) { + if (!categoryEntry.isDirectory()) { + continue + } + + const category = categoryEntry.name + const categoryPath = path.join(directory, category) + const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(categoryPath, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), category, file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) + plugins.push(metadata) + } catch (error) { + logger.warn(`Failed to parse plugin: ${file.name}`, { + category, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + return plugins + } + + async listAvailableSkills(): Promise { + const basePath = this.deps.getPluginsBasePath() + const skillsPath = path.join(basePath, this.deps.getPluginDirectoryName('skill')) + const skills: PluginMetadata[] = [] + + try { + await fs.promises.access(skillsPath) + } catch { + logger.warn('Skills directory not found', { skillsPath }) + return [] + } + + try { + const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) + logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + skills.push(metadata) + } catch (error) { + logger.warn(`Failed to parse skill folder: ${sourcePath}`, { + folderPath, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } catch (error) { + logger.error('Failed to scan skill directory', { + skillsPath, + error: error instanceof Error ? error.message : String(error) + }) + } + + return skills + } + + async readSourceContent(sourcePath: string): Promise { + const absolutePath = this.resolveSourcePath(sourcePath) + + try { + await fs.promises.access(absolutePath, fs.constants.R_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourcePath + } as PluginError + } + + try { + return await fs.promises.readFile(absolutePath, 'utf-8') + } catch (error) { + throw { + type: 'READ_FAILED', + path: sourcePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + resolveSourcePath(sourcePath: string): string { + const normalized = path.normalize(sourcePath) + + if (normalized.includes('..')) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path traversal detected', + path: sourcePath + } as PluginError + } + + const basePath = this.deps.getPluginsBasePath() + const absolutePath = path.join(basePath, normalized) + const resolvedPath = path.resolve(absolutePath) + + if (!resolvedPath.startsWith(path.resolve(basePath))) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path outside plugins directory', + path: sourcePath + } as PluginError + } + + return resolvedPath + } + + async ensureSkillSourceDirectory(sourceAbsolutePath: string, sourcePath: string): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(sourceAbsolutePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourceAbsolutePath + } as PluginError + } + + if (!stats.isDirectory()) { + throw { + type: 'INVALID_METADATA', + reason: 'Skill source is not a directory', + path: sourcePath + } as PluginError + } + } + + async validatePluginFile(filePath: string, maxFileSize: number): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(filePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + if (stats.size > maxFileSize) { + throw { + type: 'FILE_TOO_LARGE', + size: stats.size, + max: maxFileSize + } as PluginError + } + + const ext = path.extname(filePath).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + throw { + type: 'INVALID_FILE_TYPE', + extension: ext + } as PluginError + } + + try { + const basePath = this.deps.getPluginsBasePath() + const relativeSourcePath = path.relative(basePath, filePath) + const segments = relativeSourcePath.split(path.sep) + const rootDir = segments[0] + const agentDir = this.deps.getPluginDirectoryName('agent') + const type: 'agent' | 'command' = rootDir === agentDir ? 'agent' : 'command' + const category = path.basename(path.dirname(filePath)) + + await parsePluginMetadata(filePath, relativeSourcePath, category, type) + } catch (error) { + throw { + type: 'INVALID_METADATA', + reason: 'Failed to parse frontmatter', + path: filePath + } as PluginError + } + } + + async listInstalled(workdir: string): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + const cacheData = await this.readCacheFile(claudePath) + + if (cacheData) { + logger.debug(`Loaded ${cacheData.plugins.length} plugins from cache`, { workdir }) + return cacheData.plugins + } + + logger.info('Cache read failed, rebuilding from filesystem', { workdir }) + return await this.rebuild(workdir) + } + + async upsert(workdir: string, plugin: InstalledPlugin): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const updatedPlugin: InstalledPlugin = { + ...plugin, + metadata: { + ...plugin.metadata, + installedAt: plugin.metadata.installedAt ?? Date.now() + } + } + + const index = plugins.findIndex((p) => p.filename === updatedPlugin.filename && p.type === updatedPlugin.type) + if (index >= 0) { + plugins[index] = updatedPlugin + } else { + plugins.push(updatedPlugin) + } + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async remove(workdir: string, filename: string, type: PluginType): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const filtered = plugins.filter((p) => !(p.filename === filename && p.type === type)) + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins: filtered + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async rebuild(workdir: string): Promise { + logger.info('Rebuilding plugin cache from filesystem', { workdir }) + + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(claudePath, fs.constants.R_OK) + } catch { + logger.warn('.claude directory not found, returning empty plugin list', { claudePath }) + return [] + } + + const plugins: InstalledPlugin[] = [] + + await Promise.all([ + this.collectFilePlugins(workdir, 'agent', plugins), + this.collectFilePlugins(workdir, 'command', plugins), + this.collectSkillPlugins(workdir, plugins) + ]) + + try { + const cacheData: CachedPluginsData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + await this.writeCacheFile(claudePath, cacheData) + logger.info(`Rebuilt cache with ${plugins.length} plugins`, { workdir }) + } catch (error) { + logger.error('Failed to write cache file after rebuild', { + error: error instanceof Error ? error.message : String(error) + }) + } + + return plugins + } + + private async collectFilePlugins( + workdir: string, + type: Exclude, + plugins: InstalledPlugin[] + ): Promise { + const directory = this.deps.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch { + logger.debug(`${type} directory not found or not accessible`, { directory }) + return + } + + const files = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(directory, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, this.deps.getPluginDirectoryName(type), type) + plugins.push({ filename: file.name, type, metadata }) + } catch (error) { + logger.warn(`Failed to parse ${type} plugin: ${file.name}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async collectSkillPlugins(workdir: string, plugins: InstalledPlugin[]): Promise { + const skillsPath = this.deps.getClaudePluginDirectory(workdir, 'skill') + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(skillsPath, fs.constants.R_OK) + } catch { + logger.debug('Skills directory not found or not accessible', { skillsPath }) + return + } + + const skillDirectories = await findAllSkillDirectories(skillsPath, claudePath) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + plugins.push({ filename: metadata.filename, type: 'skill', metadata }) + } catch (error) { + logger.warn(`Failed to parse skill plugin: ${sourcePath}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async readCacheFile(claudePath: string): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + try { + const content = await fs.promises.readFile(cachePath, 'utf-8') + const data = JSON.parse(content) + return CachedPluginsDataSchema.parse(data) + } catch (err) { + logger.warn(`Failed to read cache file at ${cachePath}`, { + error: err instanceof Error ? err.message : String(err) + }) + return null + } + } + + private async writeCacheFile(claudePath: string, data: CachedPluginsData): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + const tempPath = `${cachePath}.tmp` + + const content = JSON.stringify(data, null, 2) + await fs.promises.writeFile(tempPath, content, 'utf-8') + await fs.promises.rename(tempPath, cachePath) + } +} diff --git a/src/main/services/agents/plugins/PluginInstaller.ts b/src/main/services/agents/plugins/PluginInstaller.ts new file mode 100644 index 0000000000..75acfc211f --- /dev/null +++ b/src/main/services/agents/plugins/PluginInstaller.ts @@ -0,0 +1,149 @@ +import { loggerService } from '@logger' +import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' +import type { PluginError } from '@types' +import * as crypto from 'crypto' +import * as fs from 'fs' + +const logger = loggerService.withContext('PluginInstaller') + +export class PluginInstaller { + async installFilePlugin(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const tempPath = `${destPath}.tmp` + let fileCopied = false + + try { + await fs.promises.copyFile(sourceAbsolutePath, tempPath) + fileCopied = true + logger.debug('File copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logger.debug('File moved to final location', { agentId, destPath }) + } catch (error) { + if (fileCopied) { + await this.safeUnlink(tempPath, 'temp file') + } + throw this.toPluginError('install', error) + } + } + + async uninstallFilePlugin( + agentId: string, + filename: string, + type: 'agent' | 'command', + filePath: string + ): Promise { + try { + await fs.promises.unlink(filePath) + logger.debug('Plugin file deleted', { agentId, filename, type, filePath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall', error) + } + logger.warn('Plugin file already deleted', { agentId, filename, type, filePath }) + } + } + + async updateFilePluginContent(agentId: string, filePath: string, content: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.W_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + try { + await fs.promises.writeFile(filePath, content, 'utf8') + logger.debug('Plugin content written successfully', { + agentId, + filePath, + size: Buffer.byteLength(content, 'utf8') + }) + } catch (error) { + throw { + type: 'WRITE_FAILED', + path: filePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + + return crypto.createHash('sha256').update(content).digest('hex') + } + + async installSkill(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const logContext = logger.withContext('installSkill') + let folderCopied = false + const tempPath = `${destPath}.tmp` + + try { + try { + await fs.promises.access(destPath) + await deleteDirectoryRecursive(destPath) + logContext.info('Removed existing skill folder', { agentId, destPath }) + } catch { + // No existing folder + } + + await copyDirectoryRecursive(sourceAbsolutePath, tempPath) + folderCopied = true + logContext.info('Skill folder copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logContext.info('Skill folder moved to final location', { agentId, destPath }) + } catch (error) { + if (folderCopied) { + await this.safeRemoveDirectory(tempPath, 'temp folder') + } + throw this.toPluginError('install-skill', error) + } + } + + async uninstallSkill(agentId: string, folderName: string, skillPath: string): Promise { + const logContext = logger.withContext('uninstallSkill') + + try { + await deleteDirectoryRecursive(skillPath) + logContext.info('Skill folder deleted', { agentId, folderName, skillPath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall-skill', error) + } + logContext.warn('Skill folder already deleted', { agentId, folderName, skillPath }) + } + } + + private toPluginError(operation: string, error: unknown): PluginError { + return { + type: 'TRANSACTION_FAILED', + operation, + reason: error instanceof Error ? error.message : String(error) + } + } + + private async safeUnlink(targetPath: string, label: string): Promise { + try { + await fs.promises.unlink(targetPath) + logger.debug(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } + + private async safeRemoveDirectory(targetPath: string, label: string): Promise { + try { + await deleteDirectoryRecursive(targetPath) + logger.info(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } +} diff --git a/src/main/services/agents/plugins/PluginService.ts b/src/main/services/agents/plugins/PluginService.ts new file mode 100644 index 0000000000..3076522a26 --- /dev/null +++ b/src/main/services/agents/plugins/PluginService.ts @@ -0,0 +1,614 @@ +import { loggerService } from '@logger' +import { parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { + GetAgentResponse, + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginError, + PluginMetadata, + PluginType, + UninstallPluginOptions +} from '@types' +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +import { AgentService } from '../services/AgentService' +import { PluginCacheStore } from './PluginCacheStore' +import { PluginInstaller } from './PluginInstaller' + +const logger = loggerService.withContext('PluginService') + +interface PluginServiceConfig { + maxFileSize: number // bytes + cacheTimeout: number // milliseconds +} + +/** + * PluginService manages agent and command plugins from resources directory. + * + * Features: + * - Singleton pattern for consistent state management + * - Caching of available plugins for performance + * - Security validation (path traversal, file size, extensions) + * - Transactional install/uninstall operations + * - Integration with AgentService for metadata persistence + */ +export class PluginService { + private static instance: PluginService | null = null + + private availablePluginsCache: ListAvailablePluginsResult | null = null + private cacheTimestamp = 0 + private config: PluginServiceConfig + private readonly cacheStore: PluginCacheStore + private readonly installer: PluginInstaller + private readonly agentService: AgentService + + private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] + + private constructor(config?: Partial) { + this.config = { + maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default + cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default + } + this.agentService = AgentService.getInstance() + this.cacheStore = new PluginCacheStore({ + allowedExtensions: this.ALLOWED_EXTENSIONS, + getPluginDirectoryName: this.getPluginDirectoryName.bind(this), + getClaudeBasePath: this.getClaudeBasePath.bind(this), + getClaudePluginDirectory: this.getClaudePluginDirectory.bind(this), + getPluginsBasePath: this.getPluginsBasePath.bind(this) + }) + this.installer = new PluginInstaller() + + logger.info('PluginService initialized', { + maxFileSize: this.config.maxFileSize, + cacheTimeout: this.config.cacheTimeout + }) + } + + /** + * Get singleton instance + */ + static getInstance(config?: Partial): PluginService { + if (!PluginService.instance) { + PluginService.instance = new PluginService(config) + } + return PluginService.instance + } + + /** + * List all available plugins from resources directory (with caching) + */ + async listAvailable(): Promise { + const now = Date.now() + + // Return cached data if still valid + if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) { + logger.debug('Returning cached plugin list', { + cacheAge: now - this.cacheTimestamp + }) + return this.availablePluginsCache + } + + logger.info('Scanning available plugins') + + // Scan all plugin types + const [agents, commands, skills] = await Promise.all([ + this.cacheStore.listAvailableFilePlugins('agent'), + this.cacheStore.listAvailableFilePlugins('command'), + this.cacheStore.listAvailableSkills() + ]) + + const result: ListAvailablePluginsResult = { + agents, + commands, + skills, // NEW: include skills + total: agents.length + commands.length + skills.length + } + + // Update cache + this.availablePluginsCache = result + this.cacheTimestamp = now + + logger.info('Available plugins scanned', { + agentsCount: agents.length, + commandsCount: commands.length, + skillsCount: skills.length, + total: result.total + }) + + return result + } + + /** + * Install plugin with validation and transactional safety + */ + async install(options: InstallPluginOptions): Promise { + logger.info('Installing plugin', options) + + const context = await this.prepareInstallContext(options) + + if (options.type === 'skill') { + return await this.installSkillPlugin(options, context) + } + + return await this.installFilePlugin(options, context) + } + + private async prepareInstallContext(options: InstallPluginOptions): Promise<{ + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + }> { + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + const sourceAbsolutePath = this.cacheStore.resolveSourcePath(options.sourcePath) + + return { agent, workdir, sourceAbsolutePath } + } + + private async installSkillPlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + await this.cacheStore.ensureSkillSourceDirectory(sourceAbsolutePath, options.sourcePath) + + const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') + const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) + + await this.ensureClaudeDirectory(workdir, 'skill') + const destPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + metadata.filename = sanitizedFolderName + + await this.installer.installSkill(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFolderName, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: 'skill' + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFolderName, + type: 'skill', + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Skill installed successfully', { + agentId: options.agentId, + sourcePath: options.sourcePath, + folderName: sanitizedFolderName + }) + + return metadataWithInstall + } + + private async installFilePlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + if (options.type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: options.type + } as PluginError + } + + const filePluginType: 'agent' | 'command' = options.type + + await this.cacheStore.validatePluginFile(sourceAbsolutePath, this.config.maxFileSize) + + const category = path.basename(path.dirname(options.sourcePath)) + const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, filePluginType) + + const sanitizedFilename = this.sanitizeFilename(metadata.filename) + metadata.filename = sanitizedFilename + + await this.ensureClaudeDirectory(workdir, filePluginType) + const destPath = this.getClaudePluginPath(workdir, filePluginType, sanitizedFilename) + + await this.installer.installFilePlugin(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFilename, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: filePluginType + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFilename, + type: filePluginType, + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Plugin installed successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: filePluginType + }) + + return metadataWithInstall + } + + /** + * Uninstall plugin with cleanup + */ + async uninstall(options: UninstallPluginOptions): Promise { + logger.info('Uninstalling plugin', options) + + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + if (options.type === 'skill') { + const sanitizedFolderName = this.sanitizeFolderName(options.filename) + const skillPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + await this.installer.uninstallSkill(agent.id, sanitizedFolderName, skillPath) + await this.cacheStore.remove(workdir, sanitizedFolderName, 'skill') + this.removeAgentPlugin(agent, sanitizedFolderName, 'skill') + + logger.info('Skill uninstalled successfully', { + agentId: options.agentId, + folderName: sanitizedFolderName + }) + + return + } + + const sanitizedFilename = this.sanitizeFilename(options.filename) + const filePath = this.getClaudePluginPath(workdir, options.type, sanitizedFilename) + + await this.installer.uninstallFilePlugin(agent.id, sanitizedFilename, options.type, filePath) + await this.cacheStore.remove(workdir, sanitizedFilename, options.type) + this.removeAgentPlugin(agent, sanitizedFilename, options.type) + + logger.info('Plugin uninstalled successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: options.type + }) + } + + /** + * List installed plugins for an agent (from database + filesystem validation) + */ + async listInstalled(agentId: string): Promise { + logger.debug('Listing installed plugins', { agentId }) + + const agent = await this.getAgentOrThrow(agentId) + + const workdir = agent.accessible_paths?.[0] + + if (!workdir) { + logger.warn('Agent has no accessible paths', { agentId }) + return [] + } + + const plugins = await this.listInstalledFromCache(workdir) + + logger.debug('Listed installed plugins from cache', { + agentId, + count: plugins.length + }) + + return plugins + } + + /** + * Invalidate plugin cache (for development/testing) + */ + invalidateCache(): void { + this.availablePluginsCache = null + this.cacheTimestamp = 0 + logger.info('Plugin cache invalidated') + } + + // ============================================================================ + // Cache File Management (for installed plugins) + // ============================================================================ + + /** + * Read cache file from .claude/plugins.json + * Returns null if cache doesn't exist or is invalid + */ + + /** + * List installed plugins from cache file + * Falls back to filesystem scan if cache is missing or corrupt + */ + async listInstalledFromCache(workdir: string): Promise { + logger.debug('Listing installed plugins from cache', { workdir }) + return await this.cacheStore.listInstalled(workdir) + } + + /** + * Read plugin content from source (resources directory) + */ + async readContent(sourcePath: string): Promise { + logger.info('Reading plugin content', { sourcePath }) + const content = await this.cacheStore.readSourceContent(sourcePath) + logger.debug('Plugin content read successfully', { + sourcePath, + size: content.length + }) + return content + } + + /** + * Write plugin content to installed plugin (in agent's .claude directory) + * Note: Only works for file-based plugins (agents/commands), not skills + */ + async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { + logger.info('Writing plugin content', { agentId, filename, type }) + + const agent = await this.getAgentOrThrow(agentId) + const workdir = this.getWorkdirOrThrow(agent, agentId) + + await this.validateWorkdir(agent, workdir) + + // Check if plugin is installed + let installedPlugins = agent.installed_plugins ?? [] + if (installedPlugins.length === 0) { + installedPlugins = await this.cacheStore.listInstalled(workdir) + agent.installed_plugins = installedPlugins + } + const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) + + if (!installedPlugin) { + throw { + type: 'PLUGIN_NOT_INSTALLED', + filename, + agentId + } as PluginError + } + + if (type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: type + } as PluginError + } + + const filePluginType = type as 'agent' | 'command' + const filePath = this.getClaudePluginPath(workdir, filePluginType, filename) + const newContentHash = await this.installer.updateFilePluginContent(agent.id, filePath, content) + + const updatedMetadata: PluginMetadata = { + ...installedPlugin.metadata, + contentHash: newContentHash, + size: Buffer.byteLength(content, 'utf8'), + updatedAt: Date.now(), + filename, + type: filePluginType + } + const updatedPlugin: InstalledPlugin = { + filename, + type: filePluginType, + metadata: updatedMetadata + } + + await this.cacheStore.upsert(workdir, updatedPlugin) + this.upsertAgentPlugin(agent, updatedPlugin) + + logger.info('Plugin content updated successfully', { + agentId, + filename, + type: filePluginType, + newContentHash + }) + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Resolve plugin type to directory name under .claude + */ + private getPluginDirectoryName(type: PluginType): 'agents' | 'commands' | 'skills' { + if (type === 'agent') { + return 'agents' + } + if (type === 'command') { + return 'commands' + } + return 'skills' + } + + /** + * Get the base .claude directory for a workdir + */ + private getClaudeBasePath(workdir: string): string { + return path.join(workdir, '.claude') + } + + /** + * Get the directory for a specific plugin type inside .claude + */ + private getClaudePluginDirectory(workdir: string, type: PluginType): string { + return path.join(this.getClaudeBasePath(workdir), this.getPluginDirectoryName(type)) + } + + /** + * Get the absolute path for a plugin file/folder inside .claude + */ + private getClaudePluginPath(workdir: string, type: PluginType, filename: string): string { + return path.join(this.getClaudePluginDirectory(workdir, type), filename) + } + + /** + * Get absolute path to plugins directory (handles packaged vs dev) + */ + private getPluginsBasePath(): string { + // Use the utility function which handles both dev and production correctly + if (app.isPackaged) { + return path.join(process.resourcesPath, 'claude-code-plugins') + } + return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') + } + + /** + * Validate source path to prevent path traversal attacks + */ + private async getAgentOrThrow(agentId: string): Promise { + const agent = await this.agentService.getAgent(agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + return agent + } + + private getWorkdirOrThrow(agent: GetAgentResponse, agentId: string): string { + const workdir = agent.accessible_paths?.[0] + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + return workdir + } + + /** + * Validate workdir against agent's accessible paths + */ + private async validateWorkdir(agent: GetAgentResponse, workdir: string): Promise { + // Verify workdir is in agent's accessible_paths + if (!agent.accessible_paths?.includes(workdir)) { + throw { + type: 'INVALID_WORKDIR', + workdir, + agentId: agent.id, + message: 'Workdir not in agent accessible paths' + } as PluginError + } + + // Verify workdir exists and is accessible + try { + await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) + } catch (error) { + throw { + type: 'WORKDIR_NOT_FOUND', + workdir, + message: 'Workdir does not exist or is not accessible' + } as PluginError + } + } + + private upsertAgentPlugin(agent: GetAgentResponse, plugin: InstalledPlugin): void { + const existing = agent.installed_plugins ?? [] + const filtered = existing.filter((p) => !(p.filename === plugin.filename && p.type === plugin.type)) + agent.installed_plugins = [...filtered, plugin] + } + + private removeAgentPlugin(agent: GetAgentResponse, filename: string, type: PluginType): void { + if (!agent.installed_plugins) { + agent.installed_plugins = [] + return + } + agent.installed_plugins = agent.installed_plugins.filter((p) => !(p.filename === filename && p.type === type)) + } + + /** + * Sanitize filename to remove unsafe characters (for agents/commands) + */ + private sanitizeFilename(filename: string): string { + // Remove path separators + let sanitized = filename.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore, dot) + sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') + + // Ensure .md extension + if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { + sanitized += '.md' + } + + return sanitized + } + + /** + * Sanitize folder name for skills (different rules than file names) + * NO dots allowed to avoid confusion with file extensions + */ + private sanitizeFolderName(folderName: string): string { + // Remove path separators + let sanitized = folderName.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore) + // NOTE: No dots allowed to avoid confusion with file extensions + sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') + + // Validate no extension was provided + if (folderName.includes('.')) { + logger.warn('Skill folder name contained dots, sanitized', { + original: folderName, + sanitized + }) + } + + return sanitized + } + + /** + * Ensure .claude subdirectory exists for the given plugin type + */ + private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { + const typeDir = this.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.mkdir(typeDir, { recursive: true }) + logger.debug('Ensured directory exists', { typeDir }) + } catch (error) { + logger.error('Failed to create directory', { + typeDir, + error: error instanceof Error ? error.message : String(error) + }) + throw { + type: 'PERMISSION_DENIED', + path: typeDir + } as PluginError + } + } +} + +export const pluginService = PluginService.getInstance() diff --git a/src/main/services/agents/services/AgentService.ts b/src/main/services/agents/services/AgentService.ts index 53af37f670..c3ae2fb794 100644 --- a/src/main/services/agents/services/AgentService.ts +++ b/src/main/services/agents/services/AgentService.ts @@ -1,5 +1,7 @@ import path from 'node:path' +import { loggerService } from '@logger' +import { pluginService } from '@main/services/agents/plugins/PluginService' import { getDataPath } from '@main/utils' import { AgentBaseSchema, @@ -17,6 +19,8 @@ import { BaseService } from '../BaseService' import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema' import { AgentModelField } from '../errors' +const logger = loggerService.withContext('AgentService') + export class AgentService extends BaseService { private static instance: AgentService | null = null private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model'] @@ -92,6 +96,24 @@ export class AgentService extends BaseService { const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse agent.tools = await this.listMcpTools(agent.type, agent.mcps) + + // Load installed_plugins from cache file instead of database + const workdir = agent.accessible_paths?.[0] + if (workdir) { + try { + agent.installed_plugins = await pluginService.listInstalledFromCache(workdir) + } catch (error) { + // Log error but don't fail the request + logger.warn(`Failed to load installed plugins for agent ${id}`, { + workdir, + error: error instanceof Error ? error.message : String(error) + }) + agent.installed_plugins = [] + } + } else { + agent.installed_plugins = [] + } + return agent } diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx index 4d51ce06f3..1ef93a5343 100644 --- a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx @@ -1,5 +1,5 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' -import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react' +import { Button, Chip, ScrollShadow } from '@heroui/react' import { loggerService } from '@logger' import { useAppDispatch, useAppSelector } from '@renderer/store' import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions' @@ -54,7 +54,6 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) { const isSubmittingAllow = request?.status === 'submitting-allow' const isSubmittingDeny = request?.status === 'submitting-deny' const isSubmitting = isSubmittingAllow || isSubmittingDeny - const hasSuggestions = (request?.suggestions?.length ?? 0) > 0 const handleDecision = useCallback( async ( @@ -147,37 +146,16 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) { {t('agent.toolPermission.button.cancel')} - {hasSuggestions ? ( - - - - - ) : ( - - )} +