@sirmrmarty/n8n-nodes-tmux-orchestrator
Version:
n8n nodes for orchestrating Claude AI agents through tmux sessions
470 lines (410 loc) • 16.1 kB
text/typescript
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`);
}
}