/ utils / plugins / lspPluginIntegration.ts
lspPluginIntegration.ts
  1  import { readFile } from 'fs/promises'
  2  import { join, relative, resolve } from 'path'
  3  import { z } from 'zod/v4'
  4  import type {
  5    LspServerConfig,
  6    ScopedLspServerConfig,
  7  } from '../../services/lsp/types.js'
  8  import { expandEnvVarsInString } from '../../services/mcp/envExpansion.js'
  9  import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
 10  import { logForDebugging } from '../debug.js'
 11  import { isENOENT, toError } from '../errors.js'
 12  import { logError } from '../log.js'
 13  import { jsonParse } from '../slowOperations.js'
 14  import { getPluginDataDir } from './pluginDirectories.js'
 15  import {
 16    getPluginStorageId,
 17    loadPluginOptions,
 18    type PluginOptionValues,
 19    substitutePluginVariables,
 20    substituteUserConfigVariables,
 21  } from './pluginOptionsStorage.js'
 22  import { 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   */
 28  function 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   */
 57  export 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   */
127  async 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   */
229  export 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   */
298  export 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   */
322  export 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   */
363  export 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  }