Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
PortfolioManager.tsโ€ข15.7 kB
/** * Portfolio Manager - Manages the portfolio directory structure for all element types */ import * as fs from 'fs/promises'; import * as path from 'path'; import { homedir } from 'os'; import { logger } from '../utils/logger.js'; import { ElementType, PortfolioConfig } from './types.js'; import { SecurityMonitor } from '../security/securityMonitor.js'; import { UnicodeValidator } from '../security/validators/unicodeValidator.js'; import { DefaultElementProvider } from './DefaultElementProvider.js'; import { ErrorHandler, ErrorCategory } from '../utils/ErrorHandler.js'; // Constants const ELEMENT_FILE_EXTENSIONS: Record<ElementType, string> = { [ElementType.PERSONA]: '.md', [ElementType.SKILL]: '.md', [ElementType.TEMPLATE]: '.md', [ElementType.AGENT]: '.md', [ElementType.MEMORY]: '.yaml', [ElementType.ENSEMBLE]: '.md' }; // Default extension for backward compatibility const DEFAULT_ELEMENT_FILE_EXTENSION = '.md'; export { ElementType }; export type { PortfolioConfig }; export class PortfolioManager { private static instance: PortfolioManager; private static instanceLock = false; private static initializationLock = false; private static initializationPromise: Promise<void> | null = null; private baseDir: string; private constructor(config?: PortfolioConfig) { // Get potential directory from environment or config const envDir = process.env.DOLLHOUSE_PORTFOLIO_DIR; const configDir = config?.baseDir; const defaultDir = path.join(homedir(), '.dollhouse', 'portfolio'); // Validate environment variable if provided if (envDir) { if (!path.isAbsolute(envDir)) { throw new Error('DOLLHOUSE_PORTFOLIO_DIR must be an absolute path'); } // Additional validation for suspicious paths if (envDir.includes('..') || envDir.startsWith('/etc') || envDir.startsWith('/sys')) { throw new Error('DOLLHOUSE_PORTFOLIO_DIR contains suspicious path segments'); } } // Validate config directory if provided if (configDir && !path.isAbsolute(configDir)) { throw new Error('Portfolio config baseDir must be an absolute path'); } // Use environment variable if set, otherwise config, otherwise default this.baseDir = envDir || configDir || defaultDir; logger.info(`[PortfolioManager] Portfolio base directory: ${this.baseDir}`); } public static getInstance(config?: PortfolioConfig): PortfolioManager { if (!PortfolioManager.instance) { // Check if another thread is already creating the instance if (PortfolioManager.instanceLock) { throw new Error('PortfolioManager instance is being created by another thread'); } try { PortfolioManager.instanceLock = true; PortfolioManager.instance = new PortfolioManager(config); } finally { PortfolioManager.instanceLock = false; } } return PortfolioManager.instance; } /** * Get the base portfolio directory */ public getBaseDir(): string { return this.baseDir; } /** * Get the directory for a specific element type */ public getElementDir(type: ElementType): string { return path.join(this.baseDir, type); } /** * Get the file extension for a specific element type * FIX (#1213): Expose ELEMENT_FILE_EXTENSIONS mapping for correct extension display */ public getFileExtension(type: ElementType): string { return ELEMENT_FILE_EXTENSIONS[type] || DEFAULT_ELEMENT_FILE_EXTENSION; } /** * Initialize the portfolio directory structure * Uses locking to prevent race conditions during concurrent initialization */ public async initialize(): Promise<void> { // If already initializing, wait for the existing initialization if (PortfolioManager.initializationPromise) { return PortfolioManager.initializationPromise; } // If already initialized, check if directories exist if (await this.exists()) { logger.debug('[PortfolioManager] Portfolio already initialized'); return; } // Create initialization promise to prevent concurrent initialization PortfolioManager.initializationPromise = this.performInitialization(); try { await PortfolioManager.initializationPromise; } finally { // Clear the promise after completion PortfolioManager.initializationPromise = null; } } /** * Perform the actual initialization - should only be called once */ private async performInitialization(): Promise<void> { logger.info('[PortfolioManager] Initializing portfolio directory structure'); // Create base directory try { await fs.mkdir(this.baseDir, { recursive: true }); } catch (error) { const err = error as NodeJS.ErrnoException; // In read-only environments (like Docker), we can't create directories // Log but continue - the portfolio will be empty but functional if (err.code === 'EACCES' || err.code === 'EROFS' || err.code === 'ENOENT') { logger.warn(`[PortfolioManager] Cannot create portfolio directory (read-only environment?): ${err.message}`); logger.info(`[DollhouseMCP] Running in read-only mode - portfolio features disabled`); return; } throw error; } // Create subdirectories for each element type for (const elementType of Object.values(ElementType)) { const elementDir = path.join(this.baseDir, elementType); await fs.mkdir(elementDir, { recursive: true }); logger.debug(`[PortfolioManager] Created directory: ${elementDir}`); } // Create special directories for stateful elements const agentStateDir = path.join(this.baseDir, ElementType.AGENT, '.state'); await fs.mkdir(agentStateDir, { recursive: true }); logger.info('[PortfolioManager] Portfolio directory structure initialized'); // Migration for v1.4.2 users: rename singular directories to plural await this.migrateFromSingularDirectories(); // Populate with default elements if this is a new installation // Skip during tests to avoid interference if (process.env.NODE_ENV !== 'test') { try { const defaultProvider = new DefaultElementProvider(); await defaultProvider.populateDefaults(this.baseDir); } catch (error) { logger.error('[PortfolioManager] Error populating default elements:', error); // Log to stderr for MCP client visibility logger.error(`[PortfolioManager] CRITICAL: Failed to populate default elements: ${error instanceof Error ? error.message : String(error)}`); // Continue anyway - empty portfolio is valid } } } /** * Check if portfolio directory exists */ public async exists(): Promise<boolean> { try { await fs.access(this.baseDir); return true; } catch { return false; } } /** * Check if a filename appears to be a test element * SAFETY: Pattern-based filtering only, no content parsing */ public isTestElement(filename: string): boolean { // Dangerous test patterns that should never appear in production const dangerousPatterns = [ /^bin-sh/i, /^rm-rf/i, /^nc-e-bin/i, /^python-c-import/i, /^curl.*evil/i, /^wget.*malicious/i, /^eval-/i, /^exec-/i, /^bash-c-/i, /^sh-c-/i, /^powershell-/i, /^cmd-c-/i, /shell-injection/i ]; // Common test patterns const testPatterns = [ /^test-/i, /^memory-test-/i, /^yaml-test/i, /^perf-test-/i, /^stability-test-/i, /^roundtrip-test/i, /test-persona/i, /test-skill/i, /test-template/i, /test-agent/i, /\.test\./, /__test__/, /test-data/, /penetration-test/i, /metadata-test/i, /testpersona\d+/i // Generated test personas with timestamps ]; // Check dangerous patterns first if (dangerousPatterns.some(pattern => pattern.test(filename))) { logger.warn(`[PortfolioManager] Filtered dangerous test element: ${filename}`); return true; } // Check common test patterns return testPatterns.some(pattern => pattern.test(filename)); } /** * List all elements of a specific type */ public async listElements(type: ElementType): Promise<string[]> { const elementDir = this.getElementDir(type); const fileExtension = ELEMENT_FILE_EXTENSIONS[type] || DEFAULT_ELEMENT_FILE_EXTENSION; try { const files = await fs.readdir(elementDir); // Filter for correct file extension based on element type and exclude test elements return files .filter(file => file.endsWith(fileExtension)) .filter(file => !this.isTestElement(file)); } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code === 'ENOENT') { // Directory doesn't exist yet - this is expected for new installations logger.debug(`[PortfolioManager] Element directory doesn't exist yet: ${elementDir}`); return []; } if (err.code === 'EACCES' || err.code === 'EPERM') { // Permission denied - log but return empty array ErrorHandler.logError('PortfolioManager.listElements', error, { elementDir }); return []; } if (err.code === 'ENOTDIR') { // Path exists but is not a directory ErrorHandler.logError('PortfolioManager.listElements', error, { elementDir }); throw ErrorHandler.createError(`Path is not a directory: ${elementDir}`, ErrorCategory.SYSTEM_ERROR); } // For any other errors, throw with context ErrorHandler.logError('PortfolioManager.listElements', error, { elementDir }); throw ErrorHandler.wrapError(error, 'Failed to list elements', ErrorCategory.SYSTEM_ERROR); } } /** * Get full path to an element file */ public getElementPath(type: ElementType, filename: string): string { // Validate filename to prevent path traversal if (!filename || typeof filename !== 'string') { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'MEDIUM', source: 'PortfolioManager.getElementPath', details: `Invalid filename provided: ${typeof filename}`, additionalData: { elementType: type, filename: String(filename) } }); throw new Error('Invalid filename: must be a non-empty string'); } // Check for path traversal attempts if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || path.isAbsolute(filename)) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'PortfolioManager.getElementPath', details: `Path traversal attempt detected in filename: ${filename}`, additionalData: { elementType: type, filename } }); throw new Error(`Invalid filename: contains path traversal characters: ${filename}`); } // Additional validation for hidden files and special characters if (filename.startsWith('.') || filename.includes('\0')) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'MEDIUM', source: 'PortfolioManager.getElementPath', details: `Invalid filename characters detected: ${filename}`, additionalData: { elementType: type, filename, hasHiddenFile: filename.startsWith('.'), hasNullByte: filename.includes('\0') } }); throw new Error(`Invalid filename: contains invalid characters: ${filename}`); } // Ensure filename ends with .md const safeFilename = filename.endsWith('.md') ? filename : `${filename}.md`; return path.join(this.getElementDir(type), safeFilename); } /** * Check if an element exists */ public async elementExists(type: ElementType, filename: string): Promise<boolean> { try { await fs.access(this.getElementPath(type, filename)); return true; } catch { return false; } } /** * Get legacy personas directory path (for migration) */ public getLegacyPersonasDir(): string { return path.join(homedir(), '.dollhouse', 'personas'); } /** * Check if legacy personas directory exists */ public async hasLegacyPersonas(): Promise<boolean> { try { await fs.access(this.getLegacyPersonasDir()); const files = await fs.readdir(this.getLegacyPersonasDir()); return files.some(file => file.endsWith('.md')); } catch { return false; } } /** * Get portfolio statistics */ public async getStatistics(): Promise<Record<ElementType, number>> { const stats: Record<string, number> = {}; for (const elementType of Object.values(ElementType)) { const elements = await this.listElements(elementType); stats[elementType] = elements.length; } return stats as Record<ElementType, number>; } /** * Migrate from v1.4.2 singular directory names to v1.4.3 plural names * This handles the upgrade path for existing users */ private async migrateFromSingularDirectories(): Promise<void> { const oldToNew: Record<string, string> = { 'persona': 'personas', 'skill': 'skills', 'template': 'templates', 'agent': 'agents', 'memory': 'memories', 'ensemble': 'ensembles' }; for (const [oldName, newName] of Object.entries(oldToNew)) { // Unicode normalize the directory names (even though they're hardcoded, for security audit) const normalizedOld = UnicodeValidator.normalize(oldName); const normalizedNew = UnicodeValidator.normalize(newName); if (!normalizedOld.isValid || !normalizedNew.isValid) { // This should never happen with our hardcoded values, but for completeness logger.error(`[PortfolioManager] Invalid Unicode in directory names during migration`); continue; } const oldDir = path.join(this.baseDir, normalizedOld.normalizedContent); const newDir = path.join(this.baseDir, normalizedNew.normalizedContent); try { // Check if old directory exists await fs.access(oldDir); // Check if new directory already has content try { const newDirFiles = await fs.readdir(newDir); if (newDirFiles.length > 0) { logger.warn( `[PortfolioManager] Both ${oldName} and ${newName} directories exist. Keeping ${newName}, skipping migration.`, { oldDir, newDir, fileCount: newDirFiles.length } ); continue; } } catch { // New directory doesn't exist or is empty, proceed with migration } // Perform the migration logger.info(`[PortfolioManager] Migrating ${oldName} โ†’ ${newName}`); await fs.rename(oldDir, newDir); // Log security event for audit trail SecurityMonitor.logSecurityEvent({ type: 'DIRECTORY_MIGRATION', severity: 'LOW', source: 'PortfolioManager.migrateFromSingularDirectories', details: `Migrated directory from ${oldName} to ${newName} for v1.4.3 compatibility`, metadata: { oldDir, newDir } }); } catch (error) { // Old directory doesn't exist, which is fine if ((error as any).code !== 'ENOENT') { logger.error(`[PortfolioManager] Error during migration of ${oldName}:`, error); } } } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server