Skip to main content
Glama
orneryd

M.I.M.I.R - Multi-agent Intelligent Memory & Insight Repository

by orneryd
path-utils.ts18.6 kB
/** * @fileoverview Cross-platform path utilities for workspace path translation * * Handles path normalization between host systems (Windows/Mac/Linux) and * Docker container paths. Supports: * - Tilde (~) expansion to home directory * - Relative paths (../, ./) * - Windows drive letters (C:\, D:\) * - Unix absolute paths (/home/user) * - Consistent forward-slash normalization * * @module utils/path-utils * @since 1.0.0 */ import path from 'path'; import os from 'os'; import { promises as fs } from 'fs'; import * as fsSync from 'fs'; /** * Normalize a path to Unix-style forward slashes * Converts Windows backslashes to forward slashes for consistency * * @param filepath - Path to normalize * @returns Path with forward slashes * * @example * ```ts * normalizeSlashes('C:\\Users\\john\\project') // => 'C:/Users/john/project' * normalizeSlashes('/home/user/project') // => '/home/user/project' * ``` */ /** * Normalize path slashes to forward slashes * @example normalizeSlashes('C:\\Users\\file') // => 'C:/Users/file' */ export function normalizeSlashes(filepath: string): string { return filepath.replace(/\\/g, '/'); } /** * Expand tilde (~) to user's home directory * Handles both ~ and ~/ prefixes * * @param filepath - Path that may start with ~ * @returns Expanded path with home directory * * @example * ```ts * expandTilde('~/Documents/file.txt') // => '/Users/john/Documents/file.txt' * expandTilde('~') // => '/Users/john' * expandTilde('/absolute/path') // => '/absolute/path' * ``` */ /** * Expand tilde to home directory * @example expandTilde('~/src/project') // => '/Users/john/src/project' */ export function expandTilde(filepath: string): string { if (!filepath) return filepath; // Only expand if it starts with ~ or ~/ if (filepath === '~') { return os.homedir(); } if (filepath.startsWith('~/') || filepath.startsWith('~\\')) { return path.join(os.homedir(), filepath.slice(2)); } return filepath; } /** * Normalize and resolve a path to its absolute form * * Handles: * - Tilde expansion (~/) * - Relative paths (../, ./) * - Multiple slashes (//) * - Mixed slashes (\\ and /) * - Windows drive letters * * @param filepath - Path to normalize * @param basePath - Optional base path for relative resolution (default: process.cwd()) * @returns Absolute normalized path with forward slashes * * @example * ```ts * normalizeAndResolve('~/project') // => '/Users/john/project' * normalizeAndResolve('../other', '/home') // => '/other' * normalizeAndResolve('C:\\Users\\file.txt') // => 'C:/Users/file.txt' * ``` */ /** * Normalize and resolve path * @example normalizeAndResolve('~/project') // => '/Users/john/project' */ export function normalizeAndResolve(filepath: string, basePath?: string): string { if (!filepath) return filepath; // Step 1: Expand tilde let normalized = expandTilde(filepath); // Step 2: Normalize slashes to forward slashes normalized = normalizeSlashes(normalized); // Step 3: Check if this is already an absolute path // Windows absolute: starts with drive letter (C:/, D:/, etc.) // Unix absolute: starts with / const isWindowsAbsolute = /^[a-zA-Z]:\//.test(normalized); const isUnixAbsolute = normalized.startsWith('/'); const isAbsolute = isWindowsAbsolute || isUnixAbsolute; // Step 4: Resolve to absolute path (only if not already absolute or if basePath provided) if (basePath) { normalized = path.resolve(basePath, normalized); } else if (!isAbsolute) { // Only resolve relative paths - don't resolve absolute paths as they'd get prefixed with cwd normalized = path.resolve(normalized); } // If already absolute, keep as-is // Step 5: Final slash normalization (resolve might add backslashes on Windows) normalized = normalizeSlashes(normalized); // Step 6: Remove trailing slashes (except for root) // Remove all trailing slashes, not just one if (normalized.length > 1) { normalized = normalized.replace(/\/+$/, ''); } // Step 7: Collapse multiple consecutive slashes into single slash normalized = normalized.replace(/\/\/+/g, '/'); return normalized; } /** * Get the host workspace root from environment with expansion * * Resolves HOST_WORKSPACE_ROOT with: * - Tilde expansion * - Relative path resolution * - Absolute path normalization * * @returns Normalized absolute path to host workspace root * * @example * ```ts * // With HOST_WORKSPACE_ROOT=~/src * getHostWorkspaceRoot() // => '/Users/john/src' * * // With HOST_WORKSPACE_ROOT=C:\Users\john\Documents * getHostWorkspaceRoot() // => 'C:/Users/john/Documents' * ``` */ /** * Check if we're running inside a Docker container * * @returns true if running in Docker, false otherwise */ /** * Check if running in Docker container * @example if (isRunningInDocker()) console.log('In Docker'); */ export function isRunningInDocker(): boolean { // Check for common Docker indicators // 1. /.dockerenv file exists (most reliable) // 2. WORKSPACE_ROOT environment variable is set (our Docker convention) try { // Check for .dockerenv file if (fsSync.existsSync('/.dockerenv')) { return true; } } catch (e) { // Ignore errors } // Check if WORKSPACE_ROOT is set (our Docker convention) // When running locally, this should NOT be set if (process.env.WORKSPACE_ROOT) { return true; } return false; } /** * Get host workspace root path * @example const root = getHostWorkspaceRoot(); // => '/Users/john/src' */ export function getHostWorkspaceRoot(): string { const hostRoot = process.env.HOST_WORKSPACE_ROOT; // If not set, use default ~/src if (!hostRoot) { const defaultRoot = normalizeAndResolve('~/src'); console.log(`🏠 Host workspace root (default): ${defaultRoot}`); return defaultRoot; } // When running in Docker with tilde in HOST_WORKSPACE_ROOT: // Docker Compose already expanded ~ in the volume mount, so we just need to expand it here too // Note: /proc/mounts doesn't work on Docker Desktop (shows VM paths like /run/host_mark) if (isRunningInDocker() && hostRoot.startsWith('~')) { // Expand tilde manually (can't use os.homedir() in Docker - it returns container's home) // Standard Unix tilde expansion: ~ -> /home/username or /Users/username // Since we're in Docker, we need to infer the actual home directory from the env var itself // The user likely set HOST_WORKSPACE_ROOT=~/src in .env, which means their home + /src // CRITICAL: Just use the value as-is and let file queries match against it // Files are stored with host paths, and queries should use the same format // console.log(`🏠 Host workspace root (Docker with tilde): keeping as ${hostRoot} for path matching`); return normalizeSlashes(hostRoot); } // When running in Docker without tilde, use as-is if (isRunningInDocker()) { const normalized = normalizeSlashes(hostRoot); // console.log(`🏠 Host workspace root (Docker): ${hostRoot} -> ${normalized}`); return normalized; } // When running locally (not in Docker), expand tilde and resolve normally const normalizedRoot = normalizeAndResolve(hostRoot); // console.log(`🏠 Host workspace root (local): ${hostRoot} -> ${normalizedRoot}`); return normalizedRoot; } /** * Translate host path to container path * * Maps paths from the host filesystem to the Docker container's workspace directory. * Uses WORKSPACE_ROOT environment variable (defaults to /workspace). * * Handles: * - Absolute host paths (when HOST_WORKSPACE_ROOT is absolute) * - Relative paths (always supported) * - Tilde paths in input (expanded if possible) * - Already-translated container paths (idempotent) * * Environment Variables: * - WORKSPACE_ROOT: Container workspace path (default: /workspace) * - HOST_WORKSPACE_ROOT: Host-side workspace path to translate from * - HOST_HOME: Host's home directory (for expanding ~ in HOST_WORKSPACE_ROOT) * * Tilde Expansion: * If HOST_WORKSPACE_ROOT contains ~ (e.g., ~/src), the function will: * 1. Try to expand using HOST_HOME if available (recommended) * 2. Log a warning and return the path unchanged if HOST_HOME is not set * * Pass HOST_HOME to Docker: docker run -e HOST_HOME=$HOME ... * * Works with ANY depth of nesting: * - C:\ or / (root) * - C:\Windows\Some\Deep\Nested\Path * - /some/deeply/nested/unix/path * * @param hostPath - Path on the host system * @returns Path inside the container (WORKSPACE_ROOT/...) * * @example * ```ts * // WORKSPACE_ROOT=/workspace, HOST_WORKSPACE_ROOT=/Users/john/src * translateHostToContainer('/Users/john/src/project') // => '/workspace/project' * translateHostToContainer('C:\\Users\\john\\project') // => '/workspace/project' (Windows) * translateHostToContainer('/workspace/project') // => '/workspace/project' (idempotent) * * // WORKSPACE_ROOT=/mnt/data, HOST_WORKSPACE_ROOT=/Users/john/src * translateHostToContainer('/Users/john/src/project') // => '/mnt/data/project' * * // HOST_WORKSPACE_ROOT=~/src with HOST_HOME=/Users/john (tilde expansion) * translateHostToContainer('/Users/john/src/project') // => '/workspace/project' * * // HOST_WORKSPACE_ROOT=~/src without HOST_HOME (cannot expand, returns path unchanged) * translateHostToContainer('/Users/john/src/project') // => '/Users/john/src/project' (with warning) * ``` */ /** * Translate host path to container path * @example translateHostToContainer('/Users/john/src/project') // => '/workspace/project' */ export function translateHostToContainer(hostPath: string): string { if (!hostPath) return hostPath; // Skip translation if running locally (not in Docker) if (!isRunningInDocker()) { console.log(`💻 Running locally - no path translation needed: ${hostPath}`); return normalizeAndResolve(hostPath); } // Get container workspace root (defaults to /workspace for backwards compatibility) const containerRoot = process.env.WORKSPACE_ROOT || '/workspace'; // Normalize and resolve the input path const normalizedPath = normalizeAndResolve(hostPath); // If already a container path, return as-is (idempotent) if (normalizedPath.startsWith(containerRoot)) { return normalizedPath; } // Special exception: /app paths are built-in container paths (docs, node_modules, etc.) // These are always accessible inside the container and don't need translation if (normalizedPath.startsWith('/app')) { return normalizedPath; } // Get host root from environment const hostRootEnv = process.env.HOST_WORKSPACE_ROOT; if (!hostRootEnv) { console.warn('⚠️ HOST_WORKSPACE_ROOT not set, cannot translate path'); return normalizedPath; } // Normalize the host root (handles Windows backslashes) let hostRoot = normalizeSlashes(hostRootEnv).replace(/\/$/, ''); // Remove trailing slash // Check if HOST_WORKSPACE_ROOT contains tilde const hasTilde = hostRoot.includes('~'); if (hasTilde) { // Tilde cannot be expanded using os.homedir() in Docker because it returns container's home // Instead, check for HOST_HOME environment variable (host's $HOME passed to container) const hostHome = process.env.HOST_HOME; if (hostHome) { // Expand tilde using HOST_HOME const expandedRoot = hostRoot.replace(/^~/, hostHome); console.log(`🏠 Expanded tilde using HOST_HOME: ${hostRoot} -> ${expandedRoot}`); // Update hostRoot with expanded path (reuse the variable) hostRoot = normalizeSlashes(expandedRoot).replace(/\/$/, ''); // Continue with normal processing using expanded path // (fall through to the code below) } else { // HOST_HOME not available - cannot expand tilde console.warn( '⚠️ HOST_WORKSPACE_ROOT contains tilde (~) but HOST_HOME is not set.\n' + ' Tilde expressions cannot be automatically expanded.\n' + ` Current value: ${hostRootEnv}\n\n` + ' Solutions:\n' + ' 1. Pass HOST_HOME to container: docker run -e HOST_HOME=$HOME ...\n' + ' 2. Expand tilde in Docker config: HOST_WORKSPACE_ROOT=$HOME/src\n' + ' 3. Use absolute path: HOST_WORKSPACE_ROOT=/Users/john/src\n' ); // Return normalized path as-is (cannot translate without knowing host root) return normalizedPath; } } // HOST_WORKSPACE_ROOT is absolute - we can do proper string matching // Check if the path is under the host workspace root if (normalizedPath.startsWith(hostRoot)) { // Calculate relative path from host root let relativePath = normalizedPath.substring(hostRoot.length); // Ensure relative path starts with / for clean joining if (!relativePath.startsWith('/')) { relativePath = '/' + relativePath; } // Build container path and normalize (remove trailing slashes) let containerPath = `${containerRoot}${relativePath}`; // Remove trailing slashes (except for root) if (containerPath.length > 1) { containerPath = containerPath.replace(/\/+$/, ''); } console.log(`📍 Host -> Container: ${hostPath} -> ${containerPath}`); return containerPath; } // Path is outside workspace root - try to make it relative console.warn(`⚠️ Path is outside workspace root: ${normalizedPath} (root: ${hostRoot})`); // Try to make it relative to workspace root const relativePath = path.relative(hostRoot, normalizedPath); // If the relative path goes up (..), it's truly outside the workspace if (relativePath.startsWith('..')) { console.error(`❌ Path is outside workspace root and cannot be translated: ${normalizedPath}`); throw new Error(`Path is outside workspace root: ${normalizedPath} (root: ${hostRoot})`); } let containerPath = `${containerRoot}/${relativePath}`; // Remove trailing slashes (except for root) if (containerPath.length > 1) { containerPath = containerPath.replace(/\/+$/, ''); } console.log(`📍 Host -> Container (relative): ${hostPath} -> ${containerPath}`); return containerPath; } /** * Translate container path to host path * * Maps paths from the Docker container's workspace directory back to the host filesystem. * Uses WORKSPACE_ROOT environment variable (defaults to /workspace). * * Environment Variables: * - WORKSPACE_ROOT: Container workspace path (default: /workspace) * - HOST_WORKSPACE_ROOT: Host-side workspace path to translate to * * @param containerPath - Path inside the container (WORKSPACE_ROOT/...) * @returns Path on the host system * * @example * ```ts * // WORKSPACE_ROOT=/workspace, HOST_WORKSPACE_ROOT=/Users/john/src * translateContainerToHost('/workspace/project') // => '/Users/john/src/project' * translateContainerToHost('/workspace/sub/file.txt') // => '/Users/john/src/sub/file.txt' * translateContainerToHost('/other/path') // => '/other/path' (passthrough) * * // WORKSPACE_ROOT=/mnt/data, HOST_WORKSPACE_ROOT=/Users/john/src * translateContainerToHost('/mnt/data/project') // => '/Users/john/src/project' * ``` */ /** * Translate container path to host path * @example translateContainerToHost('/workspace/project') // => '/Users/john/src/project' */ export function translateContainerToHost(containerPath: string): string { if (!containerPath) return containerPath; // Skip translation if running locally (not in Docker) if (!isRunningInDocker()) { console.log(`💻 Running locally - no path translation needed: ${containerPath}`); return normalizeSlashes(containerPath); } // Get container workspace root (defaults to /workspace for backwards compatibility) const containerRoot = process.env.WORKSPACE_ROOT || '/workspace'; // Normalize the container path const normalizedPath = normalizeSlashes(containerPath); // Get host root const hostRoot = getHostWorkspaceRoot(); // Check if the path starts with container workspace root if (normalizedPath.startsWith(containerRoot)) { // Calculate relative path from container root const relativePath = normalizedPath.substring(containerRoot.length); // Build host path const hostPath = `${hostRoot}${relativePath}`; console.log(`📍 Container -> Host: ${containerPath} -> ${hostPath}`); return hostPath; } // Not a workspace path - return as-is return containerPath; } /** * Validate that a path exists on the filesystem * * @param filepath - Path to validate * @returns true if path exists, false otherwise */ export async function pathExists(filepath: string): Promise<boolean> { try { await fs.access(filepath); return true; } catch { return false; } } /** * Validate and sanitize a user-provided path * * Performs security checks and normalization: * - Rejects empty/null paths * - Expands tilde and resolves relative paths * - Normalizes to absolute path * - Does NOT check if path exists (use pathExists for that) * * @param userPath - User-provided path * @param allowRelative - Whether to allow relative paths (default: true) * @returns Sanitized absolute path * @throws Error if path is invalid or fails security checks * * @example * ```ts * validateAndSanitizePath('~/project') // => '/Users/john/project' * validateAndSanitizePath('../other') // => '/absolute/path/to/other' * validateAndSanitizePath('') // throws Error * ``` */ /** * Validate and sanitize user-provided path * @example validateAndSanitizePath('~/project') // => '/Users/john/project' */ export function validateAndSanitizePath(userPath: string, allowRelative: boolean = true): string { // Check for empty/null if (!userPath || typeof userPath !== 'string') { throw new Error('Path parameter is required and must be a string'); } // Normalize and resolve const resolved = normalizeAndResolve(userPath); // Security check: ensure the original path doesn't contain suspicious patterns // after resolution (e.g., null bytes, control characters) // Check for ASCII control characters (0-31) without using regex to avoid linter warnings for (let i = 0; i < userPath.length; i++) { const charCode = userPath.charCodeAt(i); if (charCode >= 0 && charCode <= 31) { throw new Error('Path contains invalid control characters'); } } return resolved; }

Latest Blog Posts

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/orneryd/Mimir'

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