source dump of claude code
at main 595 lines 21 kB view raw
1/** 2 * Shared helper functions for plugin installation 3 * 4 * This module contains common utilities used across the plugin installation 5 * system to reduce code duplication and improve maintainability. 6 */ 7 8import { randomBytes } from 'crypto' 9import { rename, rm } from 'fs/promises' 10import { dirname, join, resolve, sep } from 'path' 11import { 12 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 13 type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 14 logEvent, 15} from '../../services/analytics/index.js' 16import { getCwd } from '../cwd.js' 17import { toError } from '../errors.js' 18import { getFsImplementation } from '../fsOperations.js' 19import { logError } from '../log.js' 20import { 21 getSettingsForSource, 22 updateSettingsForSource, 23} from '../settings/settings.js' 24import { buildPluginTelemetryFields } from '../telemetry/pluginTelemetry.js' 25import { clearAllCaches } from './cacheUtils.js' 26import { 27 formatDependencyCountSuffix, 28 getEnabledPluginIdsForScope, 29 type ResolutionResult, 30 resolveDependencyClosure, 31} from './dependencyResolver.js' 32import { 33 addInstalledPlugin, 34 getGitCommitSha, 35} from './installedPluginsManager.js' 36import { getManagedPluginNames } from './managedPlugins.js' 37import { getMarketplaceCacheOnly, getPluginById } from './marketplaceManager.js' 38import { 39 isOfficialMarketplaceName, 40 parsePluginIdentifier, 41 scopeToSettingSource, 42} from './pluginIdentifier.js' 43import { 44 cachePlugin, 45 getVersionedCachePath, 46 getVersionedZipCachePath, 47} from './pluginLoader.js' 48import { isPluginBlockedByPolicy } from './pluginPolicy.js' 49import { calculatePluginVersion } from './pluginVersioning.js' 50import { 51 isLocalPluginSource, 52 type PluginMarketplaceEntry, 53 type PluginScope, 54 type PluginSource, 55} from './schemas.js' 56import { 57 convertDirectoryToZipInPlace, 58 isPluginZipCacheEnabled, 59} from './zipCache.js' 60 61/** 62 * Plugin installation metadata for installed_plugins.json 63 */ 64export type PluginInstallationInfo = { 65 pluginId: string 66 installPath: string 67 version?: string 68} 69 70/** 71 * Get current ISO timestamp 72 */ 73export function getCurrentTimestamp(): string { 74 return new Date().toISOString() 75} 76 77/** 78 * Validate that a resolved path stays within a base directory. 79 * Prevents path traversal attacks where malicious paths like './../../../etc/passwd' 80 * could escape the expected directory. 81 * 82 * @param basePath - The base directory that the resolved path must stay within 83 * @param relativePath - The relative path to validate 84 * @returns The validated absolute path 85 * @throws Error if the path would escape the base directory 86 */ 87export function validatePathWithinBase( 88 basePath: string, 89 relativePath: string, 90): string { 91 const resolvedPath = resolve(basePath, relativePath) 92 const normalizedBase = resolve(basePath) + sep 93 94 // Check if the resolved path starts with the base path 95 // Adding sep ensures we don't match partial directory names 96 // e.g., /foo/bar should not match /foo/barbaz 97 if ( 98 !resolvedPath.startsWith(normalizedBase) && 99 resolvedPath !== resolve(basePath) 100 ) { 101 throw new Error( 102 `Path traversal detected: "${relativePath}" would escape the base directory`, 103 ) 104 } 105 106 return resolvedPath 107} 108 109/** 110 * Cache a plugin (local or external) and add it to installed_plugins.json 111 * 112 * This function combines the common pattern of: 113 * 1. Caching a plugin to ~/.claude/plugins/cache/ 114 * 2. Adding it to the installed plugins registry 115 * 116 * Both local plugins (with string source like "./path") and external plugins 117 * (with object source like {source: "github", ...}) are cached to the same 118 * location to ensure consistent behavior. 119 * 120 * @param pluginId - Plugin ID in "plugin@marketplace" format 121 * @param entry - Plugin marketplace entry 122 * @param scope - Installation scope (user, project, local, or managed). Defaults to 'user'. 123 * 'managed' scope is used for plugins installed automatically from managed settings. 124 * @param projectPath - Project path (required for project/local scopes) 125 * @param localSourcePath - For local plugins, the resolved absolute path to the source directory 126 * @returns The installation path 127 */ 128export async function cacheAndRegisterPlugin( 129 pluginId: string, 130 entry: PluginMarketplaceEntry, 131 scope: PluginScope = 'user', 132 projectPath?: string, 133 localSourcePath?: string, 134): Promise<string> { 135 // For local plugins, we need the resolved absolute path 136 // Cast to PluginSource since cachePlugin handles any string path at runtime 137 const source: PluginSource = 138 typeof entry.source === 'string' && localSourcePath 139 ? (localSourcePath as PluginSource) 140 : entry.source 141 142 const cacheResult = await cachePlugin(source, { 143 manifest: entry as PluginMarketplaceEntry, 144 }) 145 146 // For local plugins, use the original source path for Git SHA calculation 147 // because the cached temp directory doesn't have .git (it's copied from a 148 // subdirectory of the marketplace git repo). For external plugins, use the 149 // cached path. For git-subdir sources, cachePlugin already captured the SHA 150 // before discarding the ephemeral clone (the extracted subdir has no .git). 151 const pathForGitSha = localSourcePath || cacheResult.path 152 const gitCommitSha = 153 cacheResult.gitCommitSha ?? (await getGitCommitSha(pathForGitSha)) 154 155 const now = getCurrentTimestamp() 156 const version = await calculatePluginVersion( 157 pluginId, 158 entry.source, 159 cacheResult.manifest, 160 pathForGitSha, 161 entry.version, 162 cacheResult.gitCommitSha, 163 ) 164 165 // Move the cached plugin to the versioned path: cache/marketplace/plugin/version/ 166 const versionedPath = getVersionedCachePath(pluginId, version) 167 let finalPath = cacheResult.path 168 169 // Only move if the paths are different and plugin was cached to a different location 170 if (cacheResult.path !== versionedPath) { 171 // Create the versioned directory structure 172 await getFsImplementation().mkdir(dirname(versionedPath)) 173 174 // Remove existing versioned path if present (force: no-op if missing) 175 await rm(versionedPath, { recursive: true, force: true }) 176 177 // Check if versionedPath is a subdirectory of cacheResult.path 178 // This happens when marketplace name equals plugin name (e.g., "exa-mcp-server@exa-mcp-server") 179 // In this case, we can't directly rename because we'd be moving a directory into itself 180 const normalizedCachePath = cacheResult.path.endsWith(sep) 181 ? cacheResult.path 182 : cacheResult.path + sep 183 const isSubdirectory = versionedPath.startsWith(normalizedCachePath) 184 185 if (isSubdirectory) { 186 // Move to a temp location first, then to final destination 187 // We can't directly rename/copy a directory into its own subdirectory 188 // Use the parent of cacheResult.path (same filesystem) to avoid EXDEV 189 // errors when /tmp is on a different filesystem (e.g., tmpfs) 190 const tempPath = join( 191 dirname(cacheResult.path), 192 `.claude-plugin-temp-${Date.now()}-${randomBytes(4).toString('hex')}`, 193 ) 194 await rename(cacheResult.path, tempPath) 195 await getFsImplementation().mkdir(dirname(versionedPath)) 196 await rename(tempPath, versionedPath) 197 } else { 198 // Move the cached plugin to the versioned location 199 await rename(cacheResult.path, versionedPath) 200 } 201 finalPath = versionedPath 202 } 203 204 // Zip cache mode: convert directory to ZIP and remove the directory 205 if (isPluginZipCacheEnabled()) { 206 const zipPath = getVersionedZipCachePath(pluginId, version) 207 await convertDirectoryToZipInPlace(finalPath, zipPath) 208 finalPath = zipPath 209 } 210 211 // Add to both V1 and V2 installed_plugins files with correct scope 212 addInstalledPlugin( 213 pluginId, 214 { 215 version, 216 installedAt: now, 217 lastUpdated: now, 218 installPath: finalPath, 219 gitCommitSha, 220 }, 221 scope, 222 projectPath, 223 ) 224 225 return finalPath 226} 227 228/** 229 * Register a plugin installation without caching 230 * 231 * Used for local plugins that are already on disk and don't need remote caching. 232 * External plugins should use cacheAndRegisterPlugin() instead. 233 * 234 * @param info - Plugin installation information 235 * @param scope - Installation scope (user, project, local, or managed). Defaults to 'user'. 236 * 'managed' scope is used for plugins registered from managed settings. 237 * @param projectPath - Project path (required for project/local scopes) 238 */ 239export function registerPluginInstallation( 240 info: PluginInstallationInfo, 241 scope: PluginScope = 'user', 242 projectPath?: string, 243): void { 244 const now = getCurrentTimestamp() 245 addInstalledPlugin( 246 info.pluginId, 247 { 248 version: info.version || 'unknown', 249 installedAt: now, 250 lastUpdated: now, 251 installPath: info.installPath, 252 }, 253 scope, 254 projectPath, 255 ) 256} 257 258/** 259 * Parse plugin ID into components 260 * 261 * @param pluginId - Plugin ID in "plugin@marketplace" format 262 * @returns Parsed components or null if invalid 263 */ 264export function parsePluginId( 265 pluginId: string, 266): { name: string; marketplace: string } | null { 267 const parts = pluginId.split('@') 268 if (parts.length !== 2 || !parts[0] || !parts[1]) { 269 return null 270 } 271 272 return { 273 name: parts[0], 274 marketplace: parts[1], 275 } 276} 277 278/** 279 * Structured result from the install core. Wrappers format messages and 280 * handle analytics/error-catching around this. 281 */ 282export type InstallCoreResult = 283 | { ok: true; closure: string[]; depNote: string } 284 | { ok: false; reason: 'local-source-no-location'; pluginName: string } 285 | { ok: false; reason: 'settings-write-failed'; message: string } 286 | { 287 ok: false 288 reason: 'resolution-failed' 289 resolution: ResolutionResult & { ok: false } 290 } 291 | { ok: false; reason: 'blocked-by-policy'; pluginName: string } 292 | { 293 ok: false 294 reason: 'dependency-blocked-by-policy' 295 pluginName: string 296 blockedDependency: string 297 } 298 299/** 300 * Format a failed ResolutionResult into a user-facing message. Unified on 301 * the richer CLI messages (the "Is the X marketplace added?" hint is useful 302 * for UI users too). 303 */ 304export function formatResolutionError( 305 r: ResolutionResult & { ok: false }, 306): string { 307 switch (r.reason) { 308 case 'cycle': 309 return `Dependency cycle: ${r.chain.join(' → ')}` 310 case 'cross-marketplace': { 311 const depMkt = parsePluginIdentifier(r.dependency).marketplace 312 const where = depMkt 313 ? `marketplace "${depMkt}"` 314 : 'a different marketplace' 315 const hint = depMkt 316 ? ` Add "${depMkt}" to allowCrossMarketplaceDependenciesOn in the ROOT marketplace's marketplace.json (the marketplace of the plugin you're installing — only its allowlist applies; no transitive trust).` 317 : '' 318 return `Dependency "${r.dependency}" (required by ${r.requiredBy}) is in ${where}, which is not in the allowlist — cross-marketplace dependencies are blocked by default. Install it manually first.${hint}` 319 } 320 case 'not-found': { 321 const { marketplace: depMkt } = parsePluginIdentifier(r.missing) 322 return depMkt 323 ? `Dependency "${r.missing}" (required by ${r.requiredBy}) not found. Is the "${depMkt}" marketplace added?` 324 : `Dependency "${r.missing}" (required by ${r.requiredBy}) not found in any configured marketplace` 325 } 326 } 327} 328 329/** 330 * Core plugin install logic, shared by the CLI path (`installPluginOp`) and 331 * the interactive UI path (`installPluginFromMarketplace`). Given a 332 * pre-resolved marketplace entry, this: 333 * 334 * 1. Guards against local-source plugins without a marketplace install 335 * location (would silently no-op otherwise). 336 * 2. Resolves the transitive dependency closure (when PLUGIN_DEPENDENCIES 337 * is on; trivial single-plugin closure otherwise). 338 * 3. Writes the entire closure to enabledPlugins in one settings update. 339 * 4. Caches each closure member (downloads/copies sources as needed). 340 * 5. Clears memoization caches. 341 * 342 * Returns a structured result. Message formatting, analytics, and top-level 343 * error wrapping stay in the caller-specific wrappers. 344 * 345 * @param marketplaceInstallLocation Pass this if the caller already has it 346 * (from a prior marketplace search) to avoid a redundant lookup. 347 */ 348export async function installResolvedPlugin({ 349 pluginId, 350 entry, 351 scope, 352 marketplaceInstallLocation, 353}: { 354 pluginId: string 355 entry: PluginMarketplaceEntry 356 scope: 'user' | 'project' | 'local' 357 marketplaceInstallLocation?: string 358}): Promise<InstallCoreResult> { 359 const settingSource = scopeToSettingSource(scope) 360 361 // ── Policy guard ── 362 // Org-blocked plugins (managed-settings.json enabledPlugins: false) cannot 363 // be installed. Checked here so all install paths (CLI, UI, hint-triggered) 364 // are covered in one place. 365 if (isPluginBlockedByPolicy(pluginId)) { 366 return { ok: false, reason: 'blocked-by-policy', pluginName: entry.name } 367 } 368 369 // ── Resolve dependency closure ── 370 // depInfo caches marketplace lookups so the materialize loop doesn't 371 // re-fetch. Seed the root if the caller gave us its install location. 372 const depInfo = new Map< 373 string, 374 { entry: PluginMarketplaceEntry; marketplaceInstallLocation: string } 375 >() 376 // Without this guard, a local-source root with undefined 377 // marketplaceInstallLocation falls through: depInfo isn't seeded, the 378 // materialize loop's `if (!info) continue` skips the root, and the user 379 // sees "Successfully installed" while nothing is cached. 380 if (isLocalPluginSource(entry.source) && !marketplaceInstallLocation) { 381 return { 382 ok: false, 383 reason: 'local-source-no-location', 384 pluginName: entry.name, 385 } 386 } 387 if (marketplaceInstallLocation) { 388 depInfo.set(pluginId, { entry, marketplaceInstallLocation }) 389 } 390 391 const rootMarketplace = parsePluginIdentifier(pluginId).marketplace 392 const allowedCrossMarketplaces = new Set( 393 (rootMarketplace 394 ? (await getMarketplaceCacheOnly(rootMarketplace)) 395 ?.allowCrossMarketplaceDependenciesOn 396 : undefined) ?? [], 397 ) 398 const resolution = await resolveDependencyClosure( 399 pluginId, 400 async id => { 401 if (depInfo.has(id)) return depInfo.get(id)!.entry 402 if (id === pluginId) return entry 403 const info = await getPluginById(id) 404 if (info) depInfo.set(id, info) 405 return info?.entry ?? null 406 }, 407 getEnabledPluginIdsForScope(settingSource), 408 allowedCrossMarketplaces, 409 ) 410 if (!resolution.ok) { 411 return { ok: false, reason: 'resolution-failed', resolution } 412 } 413 414 // ── Policy guard for transitive dependencies ── 415 // The root plugin was already checked above, but any dependency in the 416 // closure could also be policy-blocked. Check before writing to settings 417 // so a non-blocked plugin can't pull in a blocked dependency. 418 for (const id of resolution.closure) { 419 if (id !== pluginId && isPluginBlockedByPolicy(id)) { 420 return { 421 ok: false, 422 reason: 'dependency-blocked-by-policy', 423 pluginName: entry.name, 424 blockedDependency: id, 425 } 426 } 427 } 428 429 // ── ACTION: write entire closure to settings in one call ── 430 const closureEnabled: Record<string, true> = {} 431 for (const id of resolution.closure) closureEnabled[id] = true 432 const { error } = updateSettingsForSource(settingSource, { 433 enabledPlugins: { 434 ...getSettingsForSource(settingSource)?.enabledPlugins, 435 ...closureEnabled, 436 }, 437 }) 438 if (error) { 439 return { 440 ok: false, 441 reason: 'settings-write-failed', 442 message: error.message, 443 } 444 } 445 446 // ── Materialize: cache each closure member ── 447 const projectPath = scope !== 'user' ? getCwd() : undefined 448 for (const id of resolution.closure) { 449 let info = depInfo.get(id) 450 // Root wasn't pre-seeded (caller didn't pass marketplaceInstallLocation 451 // for a non-local source). Fetch now; it's needed for the cache write. 452 if (!info && id === pluginId) { 453 const mktLocation = (await getPluginById(id))?.marketplaceInstallLocation 454 if (mktLocation) info = { entry, marketplaceInstallLocation: mktLocation } 455 } 456 if (!info) continue 457 458 let localSourcePath: string | undefined 459 const { source } = info.entry 460 if (isLocalPluginSource(source)) { 461 localSourcePath = validatePathWithinBase( 462 info.marketplaceInstallLocation, 463 source, 464 ) 465 } 466 await cacheAndRegisterPlugin( 467 id, 468 info.entry, 469 scope, 470 projectPath, 471 localSourcePath, 472 ) 473 } 474 475 clearAllCaches() 476 477 const depNote = formatDependencyCountSuffix( 478 resolution.closure.filter(id => id !== pluginId), 479 ) 480 return { ok: true, closure: resolution.closure, depNote } 481} 482 483/** 484 * Result of a plugin installation operation 485 */ 486export type InstallPluginResult = 487 | { success: true; message: string } 488 | { success: false; error: string } 489 490/** 491 * Parameters for installing a plugin from marketplace 492 */ 493export type InstallPluginParams = { 494 pluginId: string 495 entry: PluginMarketplaceEntry 496 marketplaceName: string 497 scope?: 'user' | 'project' | 'local' 498 trigger?: 'hint' | 'user' 499} 500 501/** 502 * Install a single plugin from a marketplace with the specified scope. 503 * Interactive-UI wrapper around `installResolvedPlugin` — adds try/catch, 504 * analytics, and UI-style message formatting. 505 */ 506export async function installPluginFromMarketplace({ 507 pluginId, 508 entry, 509 marketplaceName, 510 scope = 'user', 511 trigger = 'user', 512}: InstallPluginParams): Promise<InstallPluginResult> { 513 try { 514 // Look up the marketplace install location for local-source plugins. 515 // Without this, plugins with relative-path sources fail from the 516 // interactive UI path (/plugin install) even though the CLI path works. 517 const pluginInfo = await getPluginById(pluginId) 518 const marketplaceInstallLocation = pluginInfo?.marketplaceInstallLocation 519 520 const result = await installResolvedPlugin({ 521 pluginId, 522 entry, 523 scope, 524 marketplaceInstallLocation, 525 }) 526 527 if (!result.ok) { 528 switch (result.reason) { 529 case 'local-source-no-location': 530 return { 531 success: false, 532 error: `Cannot install local plugin "${result.pluginName}" without marketplace install location`, 533 } 534 case 'settings-write-failed': 535 return { 536 success: false, 537 error: `Failed to update settings: ${result.message}`, 538 } 539 case 'resolution-failed': 540 return { 541 success: false, 542 error: formatResolutionError(result.resolution), 543 } 544 case 'blocked-by-policy': 545 return { 546 success: false, 547 error: `Plugin "${result.pluginName}" is blocked by your organization's policy and cannot be installed`, 548 } 549 case 'dependency-blocked-by-policy': 550 return { 551 success: false, 552 error: `Cannot install "${result.pluginName}": dependency "${result.blockedDependency}" is blocked by your organization's policy`, 553 } 554 } 555 } 556 557 // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns. 558 // plugin_id kept in additional_metadata (redacted to 'third-party' for 559 // non-official) because dbt external_claude_code_plugin_installs.sql 560 // extracts $.plugin_id for official-marketplace install tracking. Other 561 // plugin lifecycle events drop the blob key — no downstream consumers. 562 logEvent('tengu_plugin_installed', { 563 _PROTO_plugin_name: 564 entry.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 565 _PROTO_marketplace_name: 566 marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 567 plugin_id: (isOfficialMarketplaceName(marketplaceName) 568 ? pluginId 569 : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 570 trigger: 571 trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 572 install_source: (trigger === 'hint' 573 ? 'ui-suggestion' 574 : 'ui-discover') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 575 ...buildPluginTelemetryFields( 576 entry.name, 577 marketplaceName, 578 getManagedPluginNames(), 579 ), 580 ...(entry.version && { 581 version: 582 entry.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 583 }), 584 }) 585 586 return { 587 success: true, 588 message: `✓ Installed ${entry.name}${result.depNote}. Run /reload-plugins to activate.`, 589 } 590 } catch (err) { 591 const errorMessage = err instanceof Error ? err.message : String(err) 592 logError(toError(err)) 593 return { success: false, error: `Failed to install: ${errorMessage}` } 594 } 595}