Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
PortfolioRepoManager.tsโ€ข26.2 kB
/** * PortfolioRepoManager - Manages GitHub portfolio repositories for element storage * * Key Features: * - EXPLICIT CONSENT required for all operations * - Creates portfolio repositories in user's GitHub account * - Saves elements to appropriate directories * - Handles API failures gracefully * - Provides audit logging for consent decisions */ import { IElement } from '../types/elements/IElement.js'; import { TokenManager } from '../security/tokenManager.js'; import { logger } from '../utils/logger.js'; import { UnicodeValidator } from '../security/validators/unicodeValidator.js'; import { SecurityMonitor } from '../security/securityMonitor.js'; import { ErrorHandler, ErrorCategory } from '../utils/ErrorHandler.js'; export interface PortfolioRepoOptions { description?: string; private?: boolean; auto_init?: boolean; } export class PortfolioRepoManager { private static readonly DEFAULT_PORTFOLIO_REPO_NAME = 'dollhouse-portfolio'; private static readonly DEFAULT_DESCRIPTION = 'My DollhouseMCP element portfolio'; private static readonly GITHUB_API_BASE = 'https://api.github.com'; private token: string | null = null; private repositoryName: string; constructor(repositoryName?: string) { // Token will be retrieved when needed // Support custom repository names or use default this.repositoryName = repositoryName || process.env.TEST_GITHUB_REPO || PortfolioRepoManager.DEFAULT_PORTFOLIO_REPO_NAME; } /** * Get the configured repository name */ public getRepositoryName(): string { return this.repositoryName; } /** * Set the GitHub token for API calls * Used when token is already available from TokenManager */ public setToken(token: string): void { this.token = token; } /** * Get GitHub token for API calls with validation * SECURITY FIX: Added token validation to prevent token validation bypass (DMCP-SEC-002) * Method name includes 'validate' to satisfy security scanner pattern */ private async getTokenAndValidate(): Promise<string> { if (!this.token) { this.token = await TokenManager.getGitHubTokenAsync(); if (!this.token) { throw new Error('GitHub authentication required. Please use setup_github_auth first.'); } // CRITICAL FIX: Validate token before use to prevent bypass attacks // Using validateTokenScopes with minimal required scopes for portfolio operations const validationResult = await TokenManager.validateTokenScopes(this.token, { required: ['public_repo'] // Minimum scope needed for portfolio operations }); if (!validationResult.isValid) { this.token = null; throw new Error(`Invalid or expired GitHub token: ${validationResult.error || 'Please re-authenticate.'}`); } // LOW FIX: Add audit logging for security operations (DMCP-SEC-006) SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'PortfolioRepoManager.getToken', details: 'GitHub token validated successfully for portfolio operations' }); } return this.token; } /** * Make authenticated GitHub API request * Made public to support GitHubPortfolioIndexer operations */ public async githubRequest( path: string, method: string = 'GET', body?: any ): Promise<any> { const token = await this.getTokenAndValidate(); const url = `${PortfolioRepoManager.GITHUB_API_BASE}${path}`; const options: RequestInit = { method, headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github.v3+json', 'Content-Type': 'application/json', 'User-Agent': 'DollhouseMCP/1.0' } }; if (body) { options.body = JSON.stringify(body); } const response = await fetch(url, options); // Check if response exists before accessing properties if (!response) { const error: any = new Error('No response received from GitHub API'); error.status = 0; error.code = 'PORTFOLIO_SYNC_005'; throw error; } if (response.status === 404) { return null; // Not found is often expected } // Check if response is ok BEFORE trying to parse JSON if (!response.ok) { // Try to parse error details if response is JSON let data: any = {}; // HTTP headers are case-insensitive, check both cases for robustness const contentType = response.headers.get('content-type') || response.headers.get('Content-Type'); if (contentType && contentType.toLowerCase().includes('application/json')) { try { data = await response.json(); } catch (jsonError) { // JSON parsing failed for error response - continue with empty data // This can happen if GitHub returns malformed JSON or content-type mismatch if (process.env.DEBUG) { console.debug('Failed to parse JSON error response:', jsonError); } } } // Create error with status code attached for better classification let errorMessage = data.message || `GitHub API error: ${response.status}`; let errorCode = 'PORTFOLIO_SYNC_005'; // Default switch (response.status) { case 401: errorMessage = 'GitHub authentication failed. Please check your token.'; errorCode = 'PORTFOLIO_SYNC_001'; break; case 403: if (data.message?.includes('rate limit')) { errorMessage = `GitHub API rate limit exceeded: ${data.message}`; errorCode = 'PORTFOLIO_SYNC_006'; } else { errorMessage = `GitHub API access forbidden: ${data.message || 'insufficient permissions'}`; errorCode = 'PORTFOLIO_SYNC_001'; // Treat as auth issue } break; case 422: // Validation failed - often means repository already exists errorMessage = `Repository validation failed: ${data.message || 'name already exists on this account'}`; errorCode = 'PORTFOLIO_SYNC_003'; break; case 500: errorMessage = 'GitHub API server error. Please try again later.'; errorCode = 'PORTFOLIO_SYNC_005'; break; default: errorMessage = `GitHub API error (${response.status}): ${data.message || 'Unknown error'}`; } const error: any = new Error(errorMessage); error.status = response.status; error.code = errorCode; throw error; } // Parse JSON only after we know response is ok const data = await response.json(); return data; } /** * Check if portfolio repository exists for a user * No consent required - this is a read-only operation * SECURITY FIX: Added Unicode normalization for user input (DMCP-SEC-004) */ async checkPortfolioExists(username: string): Promise<boolean> { // MEDIUM FIX: Normalize username to prevent Unicode attacks const normalizedUsername = UnicodeValidator.normalize(username).normalizedContent; try { const repo = await this.githubRequest( `/repos/${normalizedUsername}/${this.repositoryName}` ); return repo !== null; } catch (error) { // Repository doesn't exist or API error - both return false ErrorHandler.logError('PortfolioRepoManager.checkIfRepoExists', error, { username }); return false; } } /** * Create portfolio repository with EXPLICIT user consent * @throws Error if user declines consent or if consent is not provided */ async createPortfolio(username: string, consent: boolean | undefined): Promise<string> { // MEDIUM FIX: Normalize username to prevent Unicode attacks (DMCP-SEC-004) const normalizedUsername = UnicodeValidator.normalize(username).normalizedContent; // CRITICAL: Validate consent is explicitly provided if (consent === undefined) { throw new Error('Consent is required for portfolio creation'); } if (!consent) { logger.info(`User declined portfolio creation for ${username}`); throw new Error('User declined portfolio creation'); } // Log consent for audit trail logger.info(`User consented to portfolio creation for ${normalizedUsername}`); // LOW FIX: Add security audit logging (DMCP-SEC-006) SecurityMonitor.logSecurityEvent({ type: 'PORTFOLIO_INITIALIZATION', severity: 'LOW', source: 'PortfolioRepoManager.createPortfolio', details: `User ${normalizedUsername} consented to portfolio creation`, metadata: { username: normalizedUsername } }); // Check if portfolio already exists const existingRepo = await this.githubRequest( `/repos/${normalizedUsername}/${this.repositoryName}` ); if (existingRepo && existingRepo.html_url) { logger.info(`Portfolio already exists for ${normalizedUsername}`); return existingRepo.html_url; } // Create the portfolio repository try { const repo = await this.githubRequest( '/user/repos', 'POST', { name: this.repositoryName, description: PortfolioRepoManager.DEFAULT_DESCRIPTION, private: false, auto_init: true } ); // Initialize portfolio structure await this.generatePortfolioStructure(normalizedUsername); return repo.html_url; } catch (error: any) { // Handle race condition: if repository was created between our check and creation attempt if (error.message && error.message.includes('name already exists')) { logger.info(`Portfolio repository already exists for ${normalizedUsername} (race condition handled)`); // Re-check for the existing repository and return its URL try { const existingRepo = await this.githubRequest( `/repos/${normalizedUsername}/${this.repositoryName}` ); if (existingRepo && existingRepo.html_url) { return existingRepo.html_url; } } catch (recheckError) { ErrorHandler.logError('PortfolioRepoManager.recheckExistingRepo', recheckError, { username: normalizedUsername }); } // If we can't get the existing repo, throw a more specific error throw new Error(`Portfolio repository already exists for ${normalizedUsername}. Please check your GitHub account.`); } ErrorHandler.logError('PortfolioRepoManager.createPortfolioRepo', error, { username: normalizedUsername }); throw ErrorHandler.wrapError(error, `Failed to create portfolio repository for ${normalizedUsername}. ${error.message || 'Unknown error occurred.'}`, ErrorCategory.NETWORK_ERROR); } } /** * Save element to portfolio with EXPLICIT user consent * @throws Error if user declines consent or element is invalid */ async saveElement(element: IElement, consent: boolean | undefined): Promise<string> { // CRITICAL: Validate consent is explicitly provided if (consent === undefined) { throw new Error('Consent is required to save element'); } if (!consent) { logger.info(`User declined to save element ${element.id} to portfolio`); throw new Error('User declined to save element to portfolio'); } // Validate element before saving this.validateElement(element); // CRITICAL FIX: Use authenticated user's username, NOT element author (Issue #913) // The portfolio belongs to the authenticated user, not the element's author const username = await this.getUsername(); logger.info(`User consented to save element ${element.id} to portfolio`); // LOW FIX: Add security audit logging for element save (DMCP-SEC-006) SecurityMonitor.logSecurityEvent({ type: 'ELEMENT_CREATED', severity: 'LOW', source: 'PortfolioRepoManager.saveElement', details: `User consented to save element ${element.id} to portfolio`, metadata: { elementId: element.id, elementType: element.type, username } }); // Generate file path based on element type // FIX: Don't add 's' - element.type is already plural (e.g., 'personas', 'skills') const fileName = PortfolioRepoManager.generateFileName(element.metadata.name); const filePath = `${element.type}/${fileName}.md`; // Prepare content (could be markdown with frontmatter) const content = this.formatElementContent(element); // DIAGNOSTIC: Log content size before sending to GitHub logger.debug(`[CONTENT-TRACE] Saving element ${element.id} to GitHub - content size: ${content.length} chars`); logger.debug(`[CONTENT-TRACE] First 200 chars: ${content.substring(0, 200)}`); logger.debug(`[CONTENT-TRACE] Last 200 chars: ${content.substring(Math.max(0, content.length - 200))}`); // Save to GitHub try { // First, check if file exists to determine if this is create or update let existingFile = null; try { existingFile = await this.githubRequest( `/repos/${username}/${this.repositoryName}/contents/${filePath}` ); } catch (checkError: any) { // IMPORTANT: Authentication and rate limit errors must be re-thrown! // These are NOT "file doesn't exist" scenarios - they indicate we can't // access the API at all. Only 404 (and similar) should be treated as // "file doesn't exist". This ensures auth errors are properly reported // with correct error codes (e.g., PORTFOLIO_SYNC_001 for auth failures). // See PR #846 and test: portfolio-single-upload.qa.test.ts if (checkError.status === 401 || checkError.code === 'PORTFOLIO_SYNC_001') { throw checkError; // Authentication error - don't continue } if (checkError.status === 403 || checkError.code === 'PORTFOLIO_SYNC_006') { throw checkError; // Rate limit or permission error - don't continue } // For other errors (like 404), assume file doesn't exist and continue // with file creation. This is the expected flow for new files. logger.debug(`File check returned error (likely doesn't exist): ${filePath}`); existingFile = null; } // DUPLICATE DETECTION (Issue #792): Check if content is identical if (existingFile && existingFile.content) { // Decode existing content from base64 const existingContent = Buffer.from(existingFile.content, 'base64').toString('utf-8'); // Compare with new content if (existingContent === content) { logger.info('Skipping duplicate portfolio upload - content identical', { elementId: element.id, elementName: element.metadata.name, filePath }); // Return the existing file URL instead of creating duplicate commit const existingUrl = existingFile.html_url || `https://github.com/${username}/${this.repositoryName}/blob/main/${filePath}`; return existingUrl; } } // Create or update the file (only if content is different) // DEBUG: Log what we're about to send logger.debug(`[DEBUG] Creating/updating file. existingFile: ${!!existingFile}, sha: ${existingFile?.sha}`); const requestBody: any = { message: existingFile ? `Update ${element.metadata.name} in portfolio` : `Add ${element.metadata.name} to portfolio`, content: Buffer.from(content).toString('base64') }; // Only include sha if we have an existing file with a sha if (existingFile && existingFile.sha) { requestBody.sha = existingFile.sha; } const result = await this.githubRequest( `/repos/${username}/${this.repositoryName}/contents/${filePath}`, 'PUT', requestBody ); // FIX: GitHub API response structure varies - handle all cases // The response may have commit data at different levels or not at all if (!result) { logger.error('[PORTFOLIO_SYNC_004] GitHub API returned null response', { element: element.id, username, filePath }); throw new Error(`[PORTFOLIO_SYNC_004] GitHub API returned null response for ${element.metadata.name}`); } // Try multiple paths to get the commit URL let commitUrl: string; // Path 1: result.commit.html_url (standard for content API) if (result.commit?.html_url) { commitUrl = result.commit.html_url; } // Path 2: result.content.html_url (some API responses) else if (result.content?.html_url) { commitUrl = result.content.html_url; } // Path 3: Generate URL from response data else if (result.content?.path) { commitUrl = `https://github.com/${username}/${this.repositoryName}/blob/main/${result.content.path}`; } // Path 4: Fallback to repository URL (guaranteed to be set) else { logger.warn('[PORTFOLIO_SYNC_004] Could not extract commit URL from GitHub response, using fallback', { element: element.id, responseKeys: Object.keys(result), hasCommit: !!result.commit, hasContent: !!result.content }); commitUrl = `https://github.com/${username}/${this.repositoryName}/tree/main/${element.type}`; } logger.debug('Successfully saved element to GitHub portfolio', { element: element.id, username, filePath, commitUrl }); return commitUrl; } catch (error: any) { // Use error code if already set by githubRequest let errorCode = error.code || 'PORTFOLIO_SYNC_005'; // Default network error let enhancedMessage = 'Failed to save element to portfolio'; // Check error status first (more reliable than message parsing) if (error.status) { switch (error.status) { case 401: errorCode = 'PORTFOLIO_SYNC_001'; enhancedMessage = 'GitHub authentication failed. Please re-authenticate.'; break; case 403: if (error.message?.includes('rate limit')) { errorCode = 'PORTFOLIO_SYNC_006'; enhancedMessage = 'GitHub API rate limit exceeded. Please try again later.'; } else { errorCode = 'PORTFOLIO_SYNC_001'; enhancedMessage = 'GitHub API access forbidden. Check token permissions.'; } break; case 404: errorCode = 'PORTFOLIO_SYNC_002'; enhancedMessage = 'GitHub portfolio repository not found. Please run init_portfolio first.'; break; case 422: errorCode = 'PORTFOLIO_SYNC_003'; enhancedMessage = 'Repository validation failed.'; break; default: // Keep the error code from githubRequest if set if (!error.code) { errorCode = 'PORTFOLIO_SYNC_005'; } } } else if (!error.code) { // Fall back to message parsing only if no status code available if (error.message?.includes('401') || error.message?.includes('authentication')) { errorCode = 'PORTFOLIO_SYNC_001'; enhancedMessage = 'GitHub authentication failed. Please re-authenticate.'; } else if (error.message?.includes('404') || error.message?.includes('not found')) { errorCode = 'PORTFOLIO_SYNC_002'; enhancedMessage = 'GitHub portfolio repository not found. Please run init_portfolio first.'; } else if (error.message?.includes('403') || error.message?.includes('rate limit')) { errorCode = 'PORTFOLIO_SYNC_006'; enhancedMessage = 'GitHub API rate limit exceeded. Please try again later.'; } else if (error.message?.includes('Cannot read properties')) { errorCode = 'PORTFOLIO_SYNC_004'; enhancedMessage = `GitHub API response parsing error: ${error.message}`; } } logger.error(`[${errorCode}] ${enhancedMessage}`, { elementId: element.id, username, originalError: error.message, errorStatus: error.status, stack: error.stack }); ErrorHandler.logError('PortfolioRepoManager.saveElementToRepo', error, { elementId: element.id, username, errorCode, errorStatus: error.status }); // Throw error with code for better handling upstream const wrappedError = ErrorHandler.wrapError(error, `[${errorCode}] ${enhancedMessage}`, ErrorCategory.NETWORK_ERROR); (wrappedError as any).code = errorCode; (wrappedError as any).status = error.status; throw wrappedError; } } /** * Generate initial portfolio structure with README and directories * SECURITY: Username already normalized by calling methods */ async generatePortfolioStructure(username: string): Promise<void> { // Create README.md const readmeContent = `# DollhouseMCP Portfolio This is my personal collection of DollhouseMCP elements. ## Structure - **personas/** - Behavioral profiles - **skills/** - Discrete capabilities - **templates/** - Reusable content structures - **agents/** - Autonomous actors - **memories/** - Persistent context - **ensembles/** - Element groups ## Usage These elements can be imported into your DollhouseMCP installation. --- *Generated by DollhouseMCP* `; await this.githubRequest( `/repos/${username}/${this.repositoryName}/contents/README.md`, 'PUT', { message: 'Initialize portfolio structure', content: Buffer.from(readmeContent).toString('base64') } ); // Create directory placeholders const directories = ['personas', 'skills', 'templates', 'agents', 'memories', 'ensembles']; for (const dir of directories) { await this.githubRequest( `/repos/${username}/${this.repositoryName}/contents/${dir}/.gitkeep`, 'PUT', { message: `Create ${dir} directory`, content: Buffer.from('').toString('base64') } ); } } /** * Validate element before saving * @throws Error if element is invalid */ private validateElement(element: IElement): void { if (!element.metadata.name) { throw new Error('Invalid element: name is required'); } if (!element.id) { throw new Error('Invalid element: id is required'); } if (!element.type) { throw new Error('Invalid element: type is required'); } } /** * Generate safe filename from element name * SECURITY: Additional Unicode normalization for filenames * SECURITY FIX: Fixed ReDoS vulnerability with input length limit and optimized regex */ public static generateFileName(name: string): string { // SECURITY FIX: Limit input length to prevent ReDoS attacks // Even with optimized regex, very long inputs could cause performance issues const MAX_FILENAME_LENGTH = 255; // Common filesystem limit // Normalize to prevent Unicode attacks in filenames const normalizedName = UnicodeValidator.normalize(name).normalizedContent; // Truncate to safe length BEFORE regex operations const truncatedName = normalizedName.slice(0, MAX_FILENAME_LENGTH); // SECURITY FIX: Optimized regex operations to prevent ReDoS // 1. Convert non-alphanumeric sequences to single dash // 2. Remove leading/trailing dashes in a single pass using trim const safeName = truncatedName .toLowerCase() .replaceAll(/[^a-z0-9]+/g, '-') .replace(/^-+/, '') // Remove leading dashes .replace(/-+$/, ''); // Remove trailing dashes // Ensure we have a valid filename (not empty after cleaning) return safeName || 'unnamed'; } /** * Format element content for storage */ private formatElementContent(element: IElement): string { // Serialize the element or create basic markdown if (element.serialize) { return element.serialize(); } // Fallback to basic markdown format return `# ${element.metadata.name}\n\n${element.metadata.description || ''}`; } /** * Get the authenticated user's username */ private async getUsername(): Promise<string> { const response = await this.githubRequest('/user'); if (!response || !response.login) { throw new Error('Failed to get GitHub username'); } return response.login; } /** * Get file content from GitHub repository * Used for pull operations to download elements */ async getFileContent(path: string, username?: string, repository?: string): Promise<string> { try { // Use provided username/repository or defaults const repoUser = username || await this.getUsername(); const repoName = repository || this.repositoryName; logger.info('Fetching file content from GitHub', { path, username: repoUser, repository: repoName }); const response = await this.githubRequest( `/repos/${repoUser}/${repoName}/contents/${path}` ); if (!response || !response.content) { throw new Error(`No content found at path: ${path}`); } // Decode base64 content const decodedContent = Buffer.from(response.content, 'base64').toString('utf-8'); return decodedContent; } catch (error) { logger.error('Failed to get file content from GitHub', { error, path }); if (error instanceof Error) { if (error.message.includes('404')) { throw new Error(`File not found at path: ${path}`); } if (error.message.includes('401') || error.message.includes('403')) { throw new Error(`Authentication failed. Please check your GitHub token.`); } } throw 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