UNPKG

@sirmrmarty/n8n-nodes-tmux-orchestrator

Version:

n8n nodes for orchestrating Claude AI agents through tmux sessions

470 lines (410 loc) 16.1 kB
import * as path from 'path'; import * as fs from 'fs'; import { promises as fsPromises } from 'fs'; export class PathResolver { private packageRoot: string; constructor() { // This will resolve to the dist directory where the compiled JS lives // Go up from dist/utils to the package root this.packageRoot = path.resolve(__dirname, '../..'); } /** * Get the path to a script file, checking multiple locations * @param scriptName - Name of the script file * @param externalDir - Optional external directory to check first * @returns Full path to the script */ async getScriptPath(scriptName: string, externalDir?: string): Promise<string> { // Validate inputs if (!scriptName || typeof scriptName !== 'string') { throw new Error('Invalid script name provided'); } if (externalDir && typeof externalDir !== 'string') { throw new Error('Invalid external directory provided'); } const checkedLocations: string[] = []; // First check if user provided an external directory if (externalDir) { const externalPath = path.join(externalDir, scriptName); checkedLocations.push(`External: ${externalPath}`); if (await this.fileExistsAsync(externalPath) && await this.isScriptAvailableAsync(externalPath)) { return externalPath; } } // Check bundled location in the package (scripts directory) const bundledPath = path.join(this.packageRoot, 'scripts', scriptName); checkedLocations.push(`Bundled: ${bundledPath}`); if (await this.fileExistsAsync(bundledPath) && await this.isScriptAvailableAsync(bundledPath)) { return bundledPath; } // Check utils directory (for backward compatibility) const utilsPath = path.join(this.packageRoot, 'utils', scriptName); checkedLocations.push(`Utils: ${utilsPath}`); if (await this.fileExistsAsync(utilsPath) && await this.isScriptAvailableAsync(utilsPath)) { return utilsPath; } // Check if installed as npm package in node_modules const npmPackagePath = await this.findInNodeModulesAsync(scriptName); if (npmPackagePath) { checkedLocations.push(`NPM Package: ${npmPackagePath}`); if (await this.fileExistsAsync(npmPackagePath) && await this.isScriptAvailableAsync(npmPackagePath)) { return npmPackagePath; } } // Fallback to checking common locations const fallbacks = [ process.env.TMUX_ORCHESTRATOR_PATH, process.env.TMUX_SCRIPTS_PATH, path.join(process.env.HOME || '', 'n8n_claude_tmux', 'scripts'), path.join(process.env.HOME || '', 'n8n_claude_tmux', 'Tmux-Orchestrator'), path.join(process.env.HOME || '', 'n8n_claude_tmux'), '/usr/local/share/tmux-orchestrator/scripts', '/usr/local/share/tmux-orchestrator', '/opt/tmux-orchestrator/scripts' ].filter(Boolean); for (const dir of fallbacks) { const fallbackPath = path.join(dir as string, scriptName); checkedLocations.push(`Fallback: ${fallbackPath}`); if (await this.fileExistsAsync(fallbackPath) && await this.isScriptAvailableAsync(fallbackPath)) { return fallbackPath; } } // If not found, throw an error with helpful message throw new Error(`Script ${scriptName} not found. Checked locations:\n${checkedLocations.map(loc => ` - ${loc}`).join('\n')}\n\nTroubleshooting:\n - Ensure tmux and python3 are installed\n - Set TMUX_ORCHESTRATOR_PATH environment variable to script directory\n - Check that scripts have execute permissions\n - Verify n8n node installation is complete`); } /** * Get the project base path from configuration or environment * @param configuredPath - Path from user configuration * @returns Resolved project base path */ getProjectBasePath(configuredPath?: string): string { if (configuredPath) { // Expand ~ to home directory if present if (configuredPath.startsWith('~')) { return path.join(process.env.HOME || '', configuredPath.slice(1)); } return configuredPath; } // Check environment variable if (process.env.TMUX_PROJECTS_PATH) { return process.env.TMUX_PROJECTS_PATH; } // Default to ~/Coding return path.join(process.env.HOME || '', 'Coding'); } /** * Find script in node_modules package structure * @param scriptName - Name of the script file * @returns Path to script in node_modules or null if not found */ private async findInNodeModulesAsync(scriptName: string): Promise<string | null> { // Start from current directory and walk up to find node_modules let currentDir = __dirname; for (let i = 0; i < 10; i++) { // Limit search depth const nodeModulesPath = path.join(currentDir, 'node_modules', '@sirmrmarty', 'n8n-nodes-tmux-orchestrator', 'scripts', scriptName); if (await this.fileExistsAsync(nodeModulesPath)) { return nodeModulesPath; } // Also check utils directory for backward compatibility const nodeModulesUtilsPath = path.join(currentDir, 'node_modules', '@sirmrmarty', 'n8n-nodes-tmux-orchestrator', 'utils', scriptName); if (await this.fileExistsAsync(nodeModulesUtilsPath)) { return nodeModulesUtilsPath; } const parentDir = path.dirname(currentDir); if (parentDir === currentDir) break; // Reached root currentDir = parentDir; } return null; } /** * Keep synchronous version for backward compatibility */ private findInNodeModules(scriptName: string): string | null { // Start from current directory and walk up to find node_modules let currentDir = __dirname; for (let i = 0; i < 10; i++) { // Limit search depth const nodeModulesPath = path.join(currentDir, 'node_modules', '@sirmrmarty', 'n8n-nodes-tmux-orchestrator', 'scripts', scriptName); if (fs.existsSync(nodeModulesPath)) { return nodeModulesPath; } // Also check utils directory for backward compatibility const nodeModulesUtilsPath = path.join(currentDir, 'node_modules', '@sirmrmarty', 'n8n-nodes-tmux-orchestrator', 'utils', scriptName); if (fs.existsSync(nodeModulesUtilsPath)) { return nodeModulesUtilsPath; } const parentDir = path.dirname(currentDir); if (parentDir === currentDir) break; // Reached root currentDir = parentDir; } return null; } /** * Check if a script exists at the given path (async version) * @param scriptPath - Path to check * @returns true if the script exists and is executable */ async isScriptAvailableAsync(scriptPath: string): Promise<boolean> { try { const stats = await fsPromises.stat(scriptPath); // Check if file exists and has execute permissions for owner, or is readable (for Python scripts) return stats.isFile() && ((stats.mode & 0o100) !== 0 || (stats.mode & 0o400) !== 0); } catch { return false; } } /** * Check if a script exists at the given path (synchronous version for backward compatibility) * @param scriptPath - Path to check * @returns true if the script exists and is executable */ isScriptAvailable(scriptPath: string): boolean { try { const stats = fs.statSync(scriptPath); // Check if file exists and has execute permissions for owner, or is readable (for Python scripts) return stats.isFile() && ((stats.mode & 0o100) !== 0 || (stats.mode & 0o400) !== 0); } catch { return false; } } /** * Get all available script paths for diagnostics (async version) * @returns Object with script names and their resolved paths */ async getAllScriptPathsAsync(externalDir?: string): Promise<Record<string, string | null>> { const scripts = [ 'tmux_wrapper.py', 'schedule_with_note.sh', 'send-claude-message.sh', 'suggest_subagent.sh' ]; const paths: Record<string, string | null> = {}; for (const script of scripts) { try { paths[script] = await this.getScriptPath(script, externalDir); } catch { paths[script] = null; } } return paths; } /** * Get all available script paths for diagnostics (synchronous version for backward compatibility) * @returns Object with script names and their resolved paths */ getAllScriptPaths(externalDir?: string): Record<string, string | null> { const scripts = [ 'tmux_wrapper.py', 'schedule_with_note.sh', 'send-claude-message.sh', 'suggest_subagent.sh' ]; const paths: Record<string, string | null> = {}; for (const script of scripts) { try { // Use synchronous version for compatibility paths[script] = this.getScriptPathSync(script, externalDir); } catch { paths[script] = null; } } return paths; } /** * Validate path safety to prevent directory traversal attacks * @param targetPath - Path to validate * @param basePath - Base path that the target must be within * @returns true if path is safe, false otherwise */ isPathSafe(targetPath: string, basePath: string): boolean { try { // Normalize and resolve paths to handle relative components const normalizedTarget = path.resolve(path.normalize(targetPath)); const normalizedBase = path.resolve(path.normalize(basePath)); // Check if the resolved target path starts with the base path if (!normalizedTarget.startsWith(normalizedBase + path.sep) && normalizedTarget !== normalizedBase) { return false; } // Additional safety checks return this.validatePathSecurity(targetPath, normalizedTarget); } catch { // Any error in path resolution indicates unsafe path return false; } } /** * Comprehensive path security validation * @param originalPath - Original path string * @param resolvedPath - Resolved absolute path * @returns true if path passes security checks */ private validatePathSecurity(originalPath: string, resolvedPath: string): boolean { // Check for directory traversal patterns const dangerousPatterns = [ /\.\.[\/\\]/, // ../ /[\/\\]\.\./, // /.. /\.\.$/, // ends with .. /^\.\.[\/\\]/, // starts with ../ /\0/, // null bytes /[\r\n]/, // line breaks /[<>:"|?*]/ // invalid filename characters on Windows ]; for (const pattern of dangerousPatterns) { if (pattern.test(originalPath)) { return false; } } // Check for suspicious resolved paths const suspiciousPaths = [ '/etc/', '/usr/bin/', '/bin/', '/sbin/', '/var/log/', '/proc/', '/sys/' ]; for (const suspiciousPath of suspiciousPaths) { if (resolvedPath.startsWith(suspiciousPath)) { return false; } } // Check path length to prevent buffer overflow attacks if (resolvedPath.length > 4096) { return false; } return true; } /** * Validate and resolve a safe path within a project directory * @param projectPath - Base project path * @param relativePath - Relative path to validate and resolve * @returns Safe resolved path * @throws Error if path is unsafe */ validateAndResolvePath(projectPath: string, relativePath: string): string { if (!projectPath || !relativePath) { throw new Error('Path validation requires both project path and relative path'); } // Ensure project path is absolute const absoluteProjectPath = path.resolve(projectPath); // Resolve the target path const targetPath = path.resolve(absoluteProjectPath, relativePath); // Validate path safety if (!this.isPathSafe(targetPath, absoluteProjectPath)) { throw new Error(`Path traversal detected: ${relativePath} resolves outside project boundary`); } return targetPath; } /** * Create a directory safely within project boundaries (async version) * @param projectPath - Base project path * @param relativePath - Relative path to create * @returns Safe resolved path */ async safeCreateDirectoryAsync(projectPath: string, relativePath: string): Promise<string> { const safePath = this.validateAndResolvePath(projectPath, relativePath); // Ensure parent directories exist securely const parentDir = path.dirname(safePath); try { await fsPromises.access(parentDir); } catch { // Only create if parent is also within project bounds if (!this.isPathSafe(parentDir, path.resolve(projectPath))) { throw new Error('Cannot create directory: parent directory outside project bounds'); } await fsPromises.mkdir(parentDir, { recursive: true, mode: 0o755 }); } return safePath; } /** * Create a directory safely within project boundaries (synchronous version for backward compatibility) * @param projectPath - Base project path * @param relativePath - Relative path to create * @returns Safe resolved path */ safeCreateDirectory(projectPath: string, relativePath: string): string { const safePath = this.validateAndResolvePath(projectPath, relativePath); // Ensure parent directories exist securely const parentDir = path.dirname(safePath); if (!fs.existsSync(parentDir)) { // Only create if parent is also within project bounds if (!this.isPathSafe(parentDir, path.resolve(projectPath))) { throw new Error('Cannot create directory: parent directory outside project bounds'); } fs.mkdirSync(parentDir, { recursive: true, mode: 0o755 }); } return safePath; } /** * Helper method to check if file exists asynchronously */ private async fileExistsAsync(filePath: string): Promise<boolean> { try { await fsPromises.access(filePath); return true; } catch { return false; } } /** * Synchronous version of getScriptPath for backward compatibility */ getScriptPathSync(scriptName: string, externalDir?: string): string { // Validate inputs if (!scriptName || typeof scriptName !== 'string') { throw new Error('Invalid script name provided'); } if (externalDir && typeof externalDir !== 'string') { throw new Error('Invalid external directory provided'); } const checkedLocations: string[] = []; // First check if user provided an external directory if (externalDir) { const externalPath = path.join(externalDir, scriptName); checkedLocations.push(`External: ${externalPath}`); if (fs.existsSync(externalPath) && this.isScriptAvailable(externalPath)) { return externalPath; } } // Check bundled location in the package (scripts directory) const bundledPath = path.join(this.packageRoot, 'scripts', scriptName); checkedLocations.push(`Bundled: ${bundledPath}`); if (fs.existsSync(bundledPath) && this.isScriptAvailable(bundledPath)) { return bundledPath; } // Check utils directory (for backward compatibility) const utilsPath = path.join(this.packageRoot, 'utils', scriptName); checkedLocations.push(`Utils: ${utilsPath}`); if (fs.existsSync(utilsPath) && this.isScriptAvailable(utilsPath)) { return utilsPath; } // Check if installed as npm package in node_modules const npmPackagePath = this.findInNodeModules(scriptName); if (npmPackagePath) { checkedLocations.push(`NPM Package: ${npmPackagePath}`); if (fs.existsSync(npmPackagePath) && this.isScriptAvailable(npmPackagePath)) { return npmPackagePath; } } // Fallback to checking common locations const fallbacks = [ process.env.TMUX_ORCHESTRATOR_PATH, process.env.TMUX_SCRIPTS_PATH, path.join(process.env.HOME || '', 'n8n_claude_tmux', 'scripts'), path.join(process.env.HOME || '', 'n8n_claude_tmux', 'Tmux-Orchestrator'), path.join(process.env.HOME || '', 'n8n_claude_tmux'), '/usr/local/share/tmux-orchestrator/scripts', '/usr/local/share/tmux-orchestrator', '/opt/tmux-orchestrator/scripts' ].filter(Boolean); for (const dir of fallbacks) { const fallbackPath = path.join(dir as string, scriptName); checkedLocations.push(`Fallback: ${fallbackPath}`); if (fs.existsSync(fallbackPath) && this.isScriptAvailable(fallbackPath)) { return fallbackPath; } } // If not found, throw an error with helpful message throw new Error(`Script ${scriptName} not found. Checked locations:\n${checkedLocations.map(loc => ` - ${loc}`).join('\n')}\n\nTroubleshooting:\n - Ensure tmux and python3 are installed\n - Set TMUX_ORCHESTRATOR_PATH environment variable to script directory\n - Check that scripts have execute permissions\n - Verify n8n node installation is complete`); } }