source dump of claude code
at main 387 lines 12 kB view raw
1import { readFile } from 'fs/promises' 2import { join, relative, resolve } from 'path' 3import { z } from 'zod/v4' 4import type { 5 LspServerConfig, 6 ScopedLspServerConfig, 7} from '../../services/lsp/types.js' 8import { expandEnvVarsInString } from '../../services/mcp/envExpansion.js' 9import type { LoadedPlugin, PluginError } from '../../types/plugin.js' 10import { logForDebugging } from '../debug.js' 11import { isENOENT, toError } from '../errors.js' 12import { logError } from '../log.js' 13import { jsonParse } from '../slowOperations.js' 14import { getPluginDataDir } from './pluginDirectories.js' 15import { 16 getPluginStorageId, 17 loadPluginOptions, 18 type PluginOptionValues, 19 substitutePluginVariables, 20 substituteUserConfigVariables, 21} from './pluginOptionsStorage.js' 22import { LspServerConfigSchema } from './schemas.js' 23 24/** 25 * Validate that a resolved path stays within the plugin directory. 26 * Prevents path traversal attacks via .. or absolute paths. 27 */ 28function validatePathWithinPlugin( 29 pluginPath: string, 30 relativePath: string, 31): string | null { 32 // Resolve both paths to absolute paths 33 const resolvedPluginPath = resolve(pluginPath) 34 const resolvedFilePath = resolve(pluginPath, relativePath) 35 36 // Check if the resolved file path is within the plugin directory 37 const rel = relative(resolvedPluginPath, resolvedFilePath) 38 39 // If relative path starts with .. or is absolute, it's outside the plugin dir 40 if (rel.startsWith('..') || resolve(rel) === rel) { 41 return null 42 } 43 44 return resolvedFilePath 45} 46 47/** 48 * Load LSP server configurations from a plugin. 49 * Checks for: 50 * 1. .lsp.json file in plugin directory 51 * 2. manifest.lspServers field 52 * 53 * @param plugin - The loaded plugin 54 * @param errors - Array to collect any errors encountered 55 * @returns Record of server name to config, or undefined if no servers 56 */ 57export async function loadPluginLspServers( 58 plugin: LoadedPlugin, 59 errors: PluginError[] = [], 60): Promise<Record<string, LspServerConfig> | undefined> { 61 const servers: Record<string, LspServerConfig> = {} 62 63 // 1. Check for .lsp.json file in plugin directory 64 const lspJsonPath = join(plugin.path, '.lsp.json') 65 try { 66 const content = await readFile(lspJsonPath, 'utf-8') 67 const parsed = jsonParse(content) 68 const result = z 69 .record(z.string(), LspServerConfigSchema()) 70 .safeParse(parsed) 71 72 if (result.success) { 73 Object.assign(servers, result.data) 74 } else { 75 const errorMsg = `LSP config validation failed for .lsp.json in plugin ${plugin.name}: ${result.error.message}` 76 logError(new Error(errorMsg)) 77 errors.push({ 78 type: 'lsp-config-invalid', 79 plugin: plugin.name, 80 serverName: '.lsp.json', 81 validationError: result.error.message, 82 source: 'plugin', 83 }) 84 } 85 } catch (error) { 86 // .lsp.json is optional, ignore if it doesn't exist 87 if (!isENOENT(error)) { 88 const _errorMsg = 89 error instanceof Error 90 ? `Failed to read/parse .lsp.json in plugin ${plugin.name}: ${error.message}` 91 : `Failed to read/parse .lsp.json file in plugin ${plugin.name}` 92 93 logError(toError(error)) 94 95 errors.push({ 96 type: 'lsp-config-invalid', 97 plugin: plugin.name, 98 serverName: '.lsp.json', 99 validationError: 100 error instanceof Error 101 ? `Failed to parse JSON: ${error.message}` 102 : 'Failed to parse JSON file', 103 source: 'plugin', 104 }) 105 } 106 } 107 108 // 2. Check manifest.lspServers field 109 if (plugin.manifest.lspServers) { 110 const manifestServers = await loadLspServersFromManifest( 111 plugin.manifest.lspServers, 112 plugin.path, 113 plugin.name, 114 errors, 115 ) 116 if (manifestServers) { 117 Object.assign(servers, manifestServers) 118 } 119 } 120 121 return Object.keys(servers).length > 0 ? servers : undefined 122} 123 124/** 125 * Load LSP servers from manifest declaration (handles multiple formats). 126 */ 127async function loadLspServersFromManifest( 128 declaration: 129 | string 130 | Record<string, LspServerConfig> 131 | Array<string | Record<string, LspServerConfig>>, 132 pluginPath: string, 133 pluginName: string, 134 errors: PluginError[], 135): Promise<Record<string, LspServerConfig> | undefined> { 136 const servers: Record<string, LspServerConfig> = {} 137 138 // Normalize to array 139 const declarations = Array.isArray(declaration) ? declaration : [declaration] 140 141 for (const decl of declarations) { 142 if (typeof decl === 'string') { 143 // Validate path to prevent directory traversal 144 const validatedPath = validatePathWithinPlugin(pluginPath, decl) 145 if (!validatedPath) { 146 const securityMsg = `Security: Path traversal attempt blocked in plugin ${pluginName}: ${decl}` 147 logError(new Error(securityMsg)) 148 logForDebugging(securityMsg, { level: 'warn' }) 149 errors.push({ 150 type: 'lsp-config-invalid', 151 plugin: pluginName, 152 serverName: decl, 153 validationError: 154 'Invalid path: must be relative and within plugin directory', 155 source: 'plugin', 156 }) 157 continue 158 } 159 160 // Load from file 161 try { 162 const content = await readFile(validatedPath, 'utf-8') 163 const parsed = jsonParse(content) 164 const result = z 165 .record(z.string(), LspServerConfigSchema()) 166 .safeParse(parsed) 167 168 if (result.success) { 169 Object.assign(servers, result.data) 170 } else { 171 const errorMsg = `LSP config validation failed for ${decl} in plugin ${pluginName}: ${result.error.message}` 172 logError(new Error(errorMsg)) 173 errors.push({ 174 type: 'lsp-config-invalid', 175 plugin: pluginName, 176 serverName: decl, 177 validationError: result.error.message, 178 source: 'plugin', 179 }) 180 } 181 } catch (error) { 182 const _errorMsg = 183 error instanceof Error 184 ? `Failed to read/parse LSP config from ${decl} in plugin ${pluginName}: ${error.message}` 185 : `Failed to read/parse LSP config file ${decl} in plugin ${pluginName}` 186 187 logError(toError(error)) 188 189 errors.push({ 190 type: 'lsp-config-invalid', 191 plugin: pluginName, 192 serverName: decl, 193 validationError: 194 error instanceof Error 195 ? `Failed to parse JSON: ${error.message}` 196 : 'Failed to parse JSON file', 197 source: 'plugin', 198 }) 199 } 200 } else { 201 // Inline configs 202 for (const [serverName, config] of Object.entries(decl)) { 203 const result = LspServerConfigSchema().safeParse(config) 204 if (result.success) { 205 servers[serverName] = result.data 206 } else { 207 const errorMsg = `LSP config validation failed for inline server "${serverName}" in plugin ${pluginName}: ${result.error.message}` 208 logError(new Error(errorMsg)) 209 errors.push({ 210 type: 'lsp-config-invalid', 211 plugin: pluginName, 212 serverName, 213 validationError: result.error.message, 214 source: 'plugin', 215 }) 216 } 217 } 218 } 219 } 220 221 return Object.keys(servers).length > 0 ? servers : undefined 222} 223 224/** 225 * Resolve environment variables for plugin LSP servers. 226 * Handles ${CLAUDE_PLUGIN_ROOT}, ${user_config.X}, and general ${VAR} 227 * substitution. Tracks missing environment variables for error reporting. 228 */ 229export function resolvePluginLspEnvironment( 230 config: LspServerConfig, 231 plugin: { path: string; source: string }, 232 userConfig?: PluginOptionValues, 233 _errors?: PluginError[], 234): LspServerConfig { 235 const allMissingVars: string[] = [] 236 237 const resolveValue = (value: string): string => { 238 // First substitute plugin-specific variables 239 let resolved = substitutePluginVariables(value, plugin) 240 241 // Then substitute user config variables if provided 242 if (userConfig) { 243 resolved = substituteUserConfigVariables(resolved, userConfig) 244 } 245 246 // Finally expand general environment variables 247 const { expanded, missingVars } = expandEnvVarsInString(resolved) 248 allMissingVars.push(...missingVars) 249 250 return expanded 251 } 252 253 const resolved = { ...config } 254 255 // Resolve command path 256 if (resolved.command) { 257 resolved.command = resolveValue(resolved.command) 258 } 259 260 // Resolve args 261 if (resolved.args) { 262 resolved.args = resolved.args.map(arg => resolveValue(arg)) 263 } 264 265 // Resolve environment variables and add CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA 266 const resolvedEnv: Record<string, string> = { 267 CLAUDE_PLUGIN_ROOT: plugin.path, 268 CLAUDE_PLUGIN_DATA: getPluginDataDir(plugin.source), 269 ...(resolved.env || {}), 270 } 271 for (const [key, value] of Object.entries(resolvedEnv)) { 272 if (key !== 'CLAUDE_PLUGIN_ROOT' && key !== 'CLAUDE_PLUGIN_DATA') { 273 resolvedEnv[key] = resolveValue(value) 274 } 275 } 276 resolved.env = resolvedEnv 277 278 // Resolve workspaceFolder if present 279 if (resolved.workspaceFolder) { 280 resolved.workspaceFolder = resolveValue(resolved.workspaceFolder) 281 } 282 283 // Log missing variables if any were found 284 if (allMissingVars.length > 0) { 285 const uniqueMissingVars = [...new Set(allMissingVars)] 286 const warnMsg = `Missing environment variables in plugin LSP config: ${uniqueMissingVars.join(', ')}` 287 logError(new Error(warnMsg)) 288 logForDebugging(warnMsg, { level: 'warn' }) 289 } 290 291 return resolved 292} 293 294/** 295 * Add plugin scope to LSP server configs 296 * This adds a prefix to server names to avoid conflicts between plugins 297 */ 298export function addPluginScopeToLspServers( 299 servers: Record<string, LspServerConfig>, 300 pluginName: string, 301): Record<string, ScopedLspServerConfig> { 302 const scopedServers: Record<string, ScopedLspServerConfig> = {} 303 304 for (const [name, config] of Object.entries(servers)) { 305 // Add plugin prefix to server name to avoid conflicts 306 const scopedName = `plugin:${pluginName}:${name}` 307 scopedServers[scopedName] = { 308 ...config, 309 scope: 'dynamic', // Use dynamic scope for plugin servers 310 source: pluginName, 311 } 312 } 313 314 return scopedServers 315} 316 317/** 318 * Get LSP servers from a specific plugin with environment variable resolution and scoping 319 * This function is called when the LSP servers need to be activated and ensures they have 320 * the proper environment variables and scope applied 321 */ 322export async function getPluginLspServers( 323 plugin: LoadedPlugin, 324 errors: PluginError[] = [], 325): Promise<Record<string, ScopedLspServerConfig> | undefined> { 326 if (!plugin.enabled) { 327 return undefined 328 } 329 330 // Use cached servers if available 331 const servers = 332 plugin.lspServers || (await loadPluginLspServers(plugin, errors)) 333 if (!servers) { 334 return undefined 335 } 336 337 // Resolve environment variables. Top-level manifest.userConfig values 338 // become available as ${user_config.KEY} in LSP command/args/env. 339 // Gate on manifest.userConfig — same rationale as buildMcpUserConfig: 340 // loadPluginOptions always returns {} so without this guard userConfig is 341 // truthy for every plugin and substituteUserConfigVariables throws on any 342 // unresolved ${user_config.X}. Also skips unneeded keychain reads. 343 const userConfig = plugin.manifest.userConfig 344 ? loadPluginOptions(getPluginStorageId(plugin)) 345 : undefined 346 const resolvedServers: Record<string, LspServerConfig> = {} 347 for (const [name, config] of Object.entries(servers)) { 348 resolvedServers[name] = resolvePluginLspEnvironment( 349 config, 350 plugin, 351 userConfig, 352 errors, 353 ) 354 } 355 356 // Add plugin scope 357 return addPluginScopeToLspServers(resolvedServers, plugin.name) 358} 359 360/** 361 * Extract all LSP servers from loaded plugins 362 */ 363export async function extractLspServersFromPlugins( 364 plugins: LoadedPlugin[], 365 errors: PluginError[] = [], 366): Promise<Record<string, ScopedLspServerConfig>> { 367 const allServers: Record<string, ScopedLspServerConfig> = {} 368 369 for (const plugin of plugins) { 370 if (!plugin.enabled) continue 371 372 const servers = await loadPluginLspServers(plugin, errors) 373 if (servers) { 374 const scopedServers = addPluginScopeToLspServers(servers, plugin.name) 375 Object.assign(allServers, scopedServers) 376 377 // Store the servers on the plugin for caching 378 plugin.lspServers = servers 379 380 logForDebugging( 381 `Loaded ${Object.keys(servers).length} LSP servers from plugin ${plugin.name}`, 382 ) 383 } 384 } 385 386 return allServers 387}