// npx-resolver.ts - Resolve npx/npm exec binaries to avoid npm parent processes import { existsSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync, openSync, readSync, closeSync, } from 'node:fs'; import { join, dirname, extname, resolve, sep } from 'node:path'; import { getAgentPath } from './_agent-dir.ts'; import { throwIfAborted } from './_abort.ts'; import crossSpawn from 'cross-spawn'; const CACHE_VERSION = 1; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const EXACT_PACKAGE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/; interface NpxCacheEntry { resolvedBin: string; resolvedAt: number; packageVersion?: string; isJs: boolean; } interface NpxCache { version: number; entries: Record; } export interface NpxResolution { binPath: string; extraArgs: string[]; isJs: boolean; } interface ParsedInvocation { packageSpec: string; binName?: string; extraArgs: string[]; } interface ParsedPackageSpec { packageName: string; exactVersion?: string; } export async function resolveNpxBinary( command: string, args: string[], signal?: AbortSignal ): Promise { throwIfAborted(signal); const parsed = command === 'npx' ? parseNpxArgs(args) : command === 'npm' ? parseNpmExecArgs(args) : null; if (!parsed) { return null; } const packageSpec = parsePackageSpec(parsed.packageSpec); const cacheKey = JSON.stringify([command, ...args]); const cache = loadNpxCache(); const cached = cache?.entries?.[cacheKey]; if ( cached && Date.now() - cached.resolvedAt < CACHE_TTL_MS && existsSync(cached.resolvedBin) && (!packageSpec?.exactVersion || cached.packageVersion === packageSpec.exactVersion) ) { return { binPath: cached.resolvedBin, extraArgs: parsed.extraArgs, isJs: cached.isJs }; } const resolved = resolveFromNpmCache(parsed.packageSpec, parsed.binName); if (resolved) { saveNpxCacheEntry(cacheKey, resolved); return { binPath: resolved.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolved.isJs }; } // Slow path: force npx cache population await forceNpxCache(parsed.packageSpec, signal); const resolvedAfterInstall = resolveFromNpmCache(parsed.packageSpec, parsed.binName); if (resolvedAfterInstall) { saveNpxCacheEntry(cacheKey, resolvedAfterInstall); return { binPath: resolvedAfterInstall.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolvedAfterInstall.isJs }; } return null; } function parseNpxArgs(args: string[]): ParsedInvocation | null { const separatorIndex = args.indexOf('--'); const before = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args; const after = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : []; const positionals: string[] = []; let packageSpec: string | undefined; let sawPackageFlag = false; let foundFirstPositional = false; for (let i = 0; i < before.length; i++) { const arg = before[i]; if (foundFirstPositional) { positionals.push(arg); continue; } if (arg === '-y' || arg === '--yes') { continue; } if (arg === '-p' || arg === '--package') { const value = before[i + 1]; if (!value || value.startsWith('-')) { return null; } if (!packageSpec) { packageSpec = value; } sawPackageFlag = true; i++; continue; } if (arg.startsWith('--package=')) { const value = arg.slice('--package='.length); if (!value) { return null; } if (!packageSpec) { packageSpec = value; } sawPackageFlag = true; continue; } if (arg.startsWith('-')) { return null; } positionals.push(arg); foundFirstPositional = true; } const separatedAfter = separatorIndex >= 0 && after.length > 0 ? ['--', ...after] : after; if (sawPackageFlag) { const binName = positionals[0]; if (!packageSpec || !binName) { return null; } const extraArgs = positionals.slice(1).concat(separatedAfter); return { packageSpec, binName, extraArgs }; } const packagePositional = positionals[0]; if (!packagePositional) { return null; } const extraArgs = positionals.slice(1).concat(separatedAfter); return { packageSpec: packagePositional, extraArgs }; } function parseNpmExecArgs(args: string[]): ParsedInvocation | null { if (args[0] !== 'exec') { return null; } const execArgs = args.slice(1); const separatorIndex = execArgs.indexOf('--'); if (separatorIndex < 0) { return null; } const before = execArgs.slice(0, separatorIndex); const after = execArgs.slice(separatorIndex + 1); let packageSpec: string | undefined; for (let i = 0; i < before.length; i++) { const arg = before[i]; if (arg === '-y' || arg === '--yes') { continue; } if (arg === '--package') { const value = before[i + 1]; if (!value || value.startsWith('-')) { return null; } if (!packageSpec) { packageSpec = value; } i++; continue; } if (arg.startsWith('--package=')) { const value = arg.slice('--package='.length); if (!value) { return null; } if (!packageSpec) { packageSpec = value; } continue; } if (arg.startsWith('-')) { return null; } } const binName = after[0]; if (!packageSpec || !binName) { return null; } const extraArgs = after.slice(1); return { packageSpec, binName, extraArgs }; } function resolveFromNpmCache(packageSpec: string, binName?: string): NpxCacheEntry | null { const cacheDir = getNpmCacheDir(); if (!cacheDir) { return null; } const parsedSpec = parsePackageSpec(packageSpec); if (!parsedSpec) { return null; } const { packageName, exactVersion } = parsedSpec; const packageDir = findCachedPackageDir(cacheDir, packageName, exactVersion); if (!packageDir) { return null; } const packageJsonPath = join(packageDir, 'package.json'); if (!existsSync(packageJsonPath)) { return null; } let pkg: { bin?: string | Record; version?: string } | null = null; try { pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { bin?: string | Record; version?: string; }; } catch { return null; } const binField = pkg?.bin; if (!binField) { return null; } const candidates = buildBinCandidates(packageName, binName); let chosenBinName: string | undefined; let binRel: string | undefined; if (typeof binField === 'string') { chosenBinName = defaultBinName(packageName); binRel = binField; } else { for (const candidate of candidates) { if (binField[candidate]) { chosenBinName = candidate; binRel = binField[candidate]; break; } } if (!binRel) { const firstEntry = Object.entries(binField)[0]; if (firstEntry) { chosenBinName = firstEntry[0]; binRel = firstEntry[1]; } } } if (!binRel) { return null; } const nodeModulesDir = findNodeModulesDir(packageDir); const binLink = chosenBinName ? join(nodeModulesDir, '.bin', chosenBinName) : null; let resolvedBin = binLink && existsSync(binLink) ? safeRealpath(binLink) : ''; if (!resolvedBin) { resolvedBin = resolve(packageDir, binRel); if (!existsSync(resolvedBin)) { return null; } } const isJs = detectJsBinary(resolvedBin); return { resolvedBin, resolvedAt: Date.now(), packageVersion: pkg?.version, isJs, }; } const FORCE_CACHE_TIMEOUT_MS = 30_000; async function forceNpxCache(packageSpec: string, signal?: AbortSignal): Promise { throwIfAborted(signal); try { await new Promise((resolve, reject) => { const proc = crossSpawn('npm', ['exec', '--yes', '--package', packageSpec, '--', 'node', '-e', '1'], { stdio: 'ignore', }); const timer = setTimeout(() => { proc.kill(); reject(new Error('timeout')); }, FORCE_CACHE_TIMEOUT_MS); const abort = () => { proc.kill(); reject(signal?.reason instanceof Error ? signal.reason : new Error('MCP request aborted')); }; signal?.addEventListener('abort', abort, { once: true }); timer.unref(); proc.on('close', () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); resolve(); }); proc.on('error', (err) => { clearTimeout(timer); signal?.removeEventListener('abort', abort); reject(err); }); }); } catch { if (signal?.aborted) { throwIfAborted(signal); } // Ignore failures, resolution will fall back to original command } throwIfAborted(signal); } function buildBinCandidates(packageName: string, explicitBin?: string): string[] { const candidates: string[] = []; if (explicitBin) { candidates.push(explicitBin); } if (packageName.startsWith('@')) { const namePart = packageName.split('/')[1] ?? ''; const scopePart = packageName.split('/')[0]?.replace('@', '') ?? ''; if (namePart) { candidates.push(namePart); } if (scopePart && namePart) { candidates.push(`${scopePart}-${namePart}`); } } else { candidates.push(packageName); } return [...new Set(candidates.filter(Boolean))]; } function parsePackageSpec(spec: string): ParsedPackageSpec | null { const trimmed = spec.trim(); if (!trimmed) { return null; } let packageName: string; let requestedVersion: string | undefined; if (trimmed.startsWith('@')) { const slashIndex = trimmed.indexOf('/'); if (slashIndex < 0) { return null; } const atIndex = trimmed.lastIndexOf('@'); if (atIndex > slashIndex) { packageName = trimmed.slice(0, atIndex); requestedVersion = trimmed.slice(atIndex + 1); } else { packageName = trimmed; } } else { const atIndex = trimmed.indexOf('@'); if (atIndex >= 0) { packageName = trimmed.slice(0, atIndex); requestedVersion = trimmed.slice(atIndex + 1); } else { packageName = trimmed; } } if (!packageName) { return null; } const normalizedVersion = requestedVersion?.replace(/^=/, '').replace(/^v/i, ''); return { packageName, exactVersion: normalizedVersion && EXACT_PACKAGE_VERSION_RE.test(normalizedVersion) ? normalizedVersion : undefined, }; } function defaultBinName(packageName: string): string { if (packageName.startsWith('@')) { const parts = packageName.split('/'); return parts[1] ?? packageName.replace('@', '').replace('/', '-'); } return packageName; } function findCachedPackageDir(cacheDir: string, packageName: string, exactVersion?: string): string | null { const npxDir = join(cacheDir, '_npx'); if (!existsSync(npxDir)) { return null; } const packagePathParts = packageName.startsWith('@') ? packageName.split('/') : [packageName]; const candidates = readdirSync(npxDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => { const full = join(npxDir, entry.name); const mtime = safeStatMtime(full); return { name: entry.name, mtime }; }) .sort((a, b) => b.mtime - a.mtime); for (const entry of candidates) { const pkgDir = join(npxDir, entry.name, 'node_modules', ...packagePathParts); const packageJsonPath = join(pkgDir, 'package.json'); if (!existsSync(packageJsonPath)) { continue; } if (exactVersion) { try { const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: unknown }; if (pkg.version !== exactVersion) { continue; } } catch { continue; } } return pkgDir; } return null; } function findNodeModulesDir(packageDir: string): string { const parts = packageDir.split(sep); const idx = parts.lastIndexOf('node_modules'); if (idx >= 0) { return parts.slice(0, idx + 1).join(sep); } return join(packageDir, '..'); } function detectJsBinary(binPath: string): boolean { const ext = extname(binPath).toLowerCase(); if (ext === '.js' || ext === '.mjs' || ext === '.cjs') { return true; } try { const fd = openSync(binPath, 'r'); try { const buf = Buffer.alloc(256); readSync(fd, buf, 0, 256, 0); const firstLine = buf.toString('utf-8').split('\n')[0] ?? ''; return firstLine.startsWith('#!') && firstLine.includes('node'); } finally { closeSync(fd); } } catch { return false; } } let npmCacheDirCached: string | null | undefined; function getNpmCacheDir(): string | null { if (npmCacheDirCached !== undefined) { return npmCacheDirCached; } if (process.env.NPM_CONFIG_CACHE) { npmCacheDirCached = process.env.NPM_CONFIG_CACHE; return npmCacheDirCached; } try { const result = crossSpawn.sync('npm', ['config', 'get', 'cache'], { encoding: 'utf-8' }); if (result.status === 0) { const path = String(result.stdout).trim(); npmCacheDirCached = path || null; return npmCacheDirCached; } } catch { npmCacheDirCached = null; return null; } npmCacheDirCached = null; return null; } function getNpxCachePath(): string { return getAgentPath('mcp-npx-cache.json'); } // ── Read-through memory cache (PERF-05) ────────────────────────── // loadNpxCache re-reads the merged JSONL on every resolve; a memory copy // keyed by { mtimeMs, size } amortizes it to one real read per identity // change. TTL rationale lives in the entries themselves (CACHE_TTL_MS per // resolvedAt); the identity cache only mirrors the file on disk. interface NpxCacheStats { loads: number; fileReads: number; memoryHits: number; } const npxCacheStats: NpxCacheStats = { loads: 0, fileReads: 0, memoryHits: 0 }; let npxMemoryCache: { identity: { mtimeMs: number; size: number } | null; data: NpxCache | null } | null = null; export function getNpxCacheStats(): NpxCacheStats { return { ...npxCacheStats }; } function npxIdentityOf(path: string): { mtimeMs: number; size: number } | null { try { const stat = statSync(path); return { mtimeMs: stat.mtimeMs, size: stat.size }; } catch { return null; } } export function loadNpxCache(): NpxCache | null { const cachePath = getNpxCachePath(); npxCacheStats.loads++; const identity = npxIdentityOf(cachePath); if (!identity) { npxMemoryCache = null; return null; } if ( npxMemoryCache?.identity && npxMemoryCache.identity.mtimeMs === identity.mtimeMs && npxMemoryCache.identity.size === identity.size ) { npxCacheStats.memoryHits++; return npxMemoryCache.data; } npxCacheStats.fileReads++; let raw: string | null = null; try { raw = readFileSync(cachePath, 'utf-8'); } catch { npxMemoryCache = { identity, data: null }; return null; } let parsed: unknown = null; try { parsed = JSON.parse(raw); } catch { npxMemoryCache = { identity, data: null }; return null; } const data = parsed && typeof parsed === 'object' && (parsed as NpxCache).version === CACHE_VERSION && (parsed as NpxCache).entries ? (parsed as NpxCache) : null; npxMemoryCache = { identity, data }; return data ? { ...data, entries: { ...data.entries } } : null; } export function saveNpxCacheEntry(key: string, entry: NpxCacheEntry): void { const cachePath = getNpxCachePath(); const dir = dirname(cachePath); mkdirSync(dir, { recursive: true }); const merged: NpxCache = { version: CACHE_VERSION, entries: {} }; try { if (existsSync(cachePath)) { const existing = JSON.parse(readFileSync(cachePath, 'utf-8')) as NpxCache; if (existing && existing.version === CACHE_VERSION && existing.entries) { merged.entries = { ...existing.entries }; } } } catch { // Ignore parse errors } merged.entries[key] = entry; const tmpPath = `${cachePath}.${process.pid}.tmp`; writeFileSync(tmpPath, JSON.stringify(merged, null, 2), 'utf-8'); renameSync(tmpPath, cachePath); // The file changed under us: drop the read-through copy. npxMemoryCache = null; } function safeRealpath(path: string): string { try { return realpathSync(path); } catch { return ''; } } function safeStatMtime(path: string): number { try { return statSync(path).mtimeMs; } catch { return 0; } }