source dump of claude code
at main 400 lines 15 kB view raw
1/** 2 * Plugin option storage and substitution. 3 * 4 * Plugins declare user-configurable options in `manifest.userConfig` — a record 5 * of field schemas matching `McpbUserConfigurationOption`. At enable time the 6 * user is prompted for values. Storage splits by `sensitive`: 7 * - `sensitive: true` → secureStorage (keychain on macOS, .credentials.json elsewhere) 8 * - everything else → settings.json `pluginConfigs[pluginId].options` 9 * 10 * `loadPluginOptions` reads and merges both. The substitution helpers are also 11 * here (moved from mcpPluginIntegration.ts) so hooks/LSP/skills don't all 12 * import from MCP-specific code. 13 */ 14 15import memoize from 'lodash-es/memoize.js' 16import type { LoadedPlugin } from '../../types/plugin.js' 17import { logForDebugging } from '../debug.js' 18import { logError } from '../log.js' 19import { getSecureStorage } from '../secureStorage/index.js' 20import { 21 getSettings_DEPRECATED, 22 updateSettingsForSource, 23} from '../settings/settings.js' 24import { 25 type UserConfigSchema, 26 type UserConfigValues, 27 validateUserConfig, 28} from './mcpbHandler.js' 29import { getPluginDataDir } from './pluginDirectories.js' 30 31export type PluginOptionValues = UserConfigValues 32export type PluginOptionSchema = UserConfigSchema 33 34/** 35 * Canonical storage key for a plugin's options in both `settings.pluginConfigs` 36 * and `secureStorage.pluginSecrets`. Today this is `plugin.source` — always 37 * `"${name}@${marketplace}"` (pluginLoader.ts:1400). `plugin.repository` is 38 * a backward-compat alias that's set to the same string (1401); don't use it 39 * for storage. UI code that manually constructs `` `${name}@${marketplace}` `` 40 * produces the same key by convention — see PluginOptionsFlow, ManagePlugins. 41 * 42 * Exists so there's exactly one place to change if the key format ever drifts. 43 */ 44export function getPluginStorageId(plugin: LoadedPlugin): string { 45 return plugin.source 46} 47 48/** 49 * Load saved option values for a plugin, merging non-sensitive (from settings) 50 * with sensitive (from secureStorage). SecureStorage wins on key collision. 51 * 52 * Memoized per-pluginId because hooks can fire per-tool-call and each call 53 * would otherwise do a settings read + keychain spawn. Cache cleared via 54 * `clearPluginOptionsCache` when settings change or plugins reload. 55 */ 56export const loadPluginOptions = memoize( 57 (pluginId: string): PluginOptionValues => { 58 const settings = getSettings_DEPRECATED() 59 const nonSensitive = 60 settings.pluginConfigs?.[pluginId]?.options ?? ({} as PluginOptionValues) 61 62 // NOTE: storage.read() spawns `security find-generic-password` on macOS 63 // (~50-100ms, synchronous). Mitigated by the memoize above (per-pluginId, 64 // session-lifetime) + keychain's own 30s TTL cache — so one blocking spawn 65 // per session per plugin-with-options. /reload-plugins clears the memoize 66 // and the next hook/MCP-load after that eats a fresh spawn. 67 const storage = getSecureStorage() 68 const sensitive = 69 storage.read()?.pluginSecrets?.[pluginId] ?? 70 ({} as Record<string, string>) 71 72 // secureStorage wins on collision — schema determines destination so 73 // collision shouldn't happen, but if a user hand-edits settings.json we 74 // trust the more secure source. 75 return { ...nonSensitive, ...sensitive } 76 }, 77) 78 79export function clearPluginOptionsCache(): void { 80 loadPluginOptions.cache?.clear?.() 81} 82 83/** 84 * Save option values, splitting by `schema[key].sensitive`. Non-sensitive go 85 * to userSettings; sensitive go to secureStorage. Writes are skipped if nothing 86 * in that category is present. 87 * 88 * Clears the load cache on success so the next `loadPluginOptions` sees fresh. 89 */ 90export function savePluginOptions( 91 pluginId: string, 92 values: PluginOptionValues, 93 schema: PluginOptionSchema, 94): void { 95 const nonSensitive: PluginOptionValues = {} 96 const sensitive: Record<string, string> = {} 97 98 for (const [key, value] of Object.entries(values)) { 99 if (schema[key]?.sensitive === true) { 100 sensitive[key] = String(value) 101 } else { 102 nonSensitive[key] = value 103 } 104 } 105 106 // Scrub sets — see saveMcpServerUserConfig (mcpbHandler.ts) for the 107 // rationale. Only keys in THIS save are scrubbed from the other store, 108 // so partial reconfigures don't lose data. 109 const sensitiveKeysInThisSave = new Set(Object.keys(sensitive)) 110 const nonSensitiveKeysInThisSave = new Set(Object.keys(nonSensitive)) 111 112 // secureStorage FIRST — if keychain fails, throw before touching 113 // settings.json so old plaintext (if any) stays as fallback. 114 const storage = getSecureStorage() 115 const existingInSecureStorage = 116 storage.read()?.pluginSecrets?.[pluginId] ?? undefined 117 const secureScrubbed = existingInSecureStorage 118 ? Object.fromEntries( 119 Object.entries(existingInSecureStorage).filter( 120 ([k]) => !nonSensitiveKeysInThisSave.has(k), 121 ), 122 ) 123 : undefined 124 const needSecureScrub = 125 secureScrubbed && 126 existingInSecureStorage && 127 Object.keys(secureScrubbed).length !== 128 Object.keys(existingInSecureStorage).length 129 if (Object.keys(sensitive).length > 0 || needSecureScrub) { 130 const existing = storage.read() ?? {} 131 if (!existing.pluginSecrets) { 132 existing.pluginSecrets = {} 133 } 134 existing.pluginSecrets[pluginId] = { 135 ...secureScrubbed, 136 ...sensitive, 137 } 138 const result = storage.update(existing) 139 if (!result.success) { 140 const err = new Error( 141 `Failed to save sensitive plugin options for ${pluginId} to secure storage`, 142 ) 143 logError(err) 144 throw err 145 } 146 if (result.warning) { 147 logForDebugging(`Plugin secrets save warning: ${result.warning}`, { 148 level: 'warn', 149 }) 150 } 151 } 152 153 // settings.json AFTER secureStorage — scrub sensitive keys via explicit 154 // undefined (mergeWith deletion pattern). 155 // 156 // TODO: getSettings_DEPRECATED returns MERGED settings across all scopes. 157 // Mutating that and writing to userSettings can leak project-scope 158 // pluginConfigs into ~/.claude/settings.json. Same pattern exists in 159 // saveMcpServerUserConfig. Safe today since pluginConfigs is only ever 160 // written here (user-scope), but will bite if we add project-scoped 161 // plugin options. 162 const settings = getSettings_DEPRECATED() 163 const existingInSettings = settings.pluginConfigs?.[pluginId]?.options ?? {} 164 const keysToScrubFromSettings = Object.keys(existingInSettings).filter(k => 165 sensitiveKeysInThisSave.has(k), 166 ) 167 if ( 168 Object.keys(nonSensitive).length > 0 || 169 keysToScrubFromSettings.length > 0 170 ) { 171 if (!settings.pluginConfigs) { 172 settings.pluginConfigs = {} 173 } 174 if (!settings.pluginConfigs[pluginId]) { 175 settings.pluginConfigs[pluginId] = {} 176 } 177 const scrubbed = Object.fromEntries( 178 keysToScrubFromSettings.map(k => [k, undefined]), 179 ) as Record<string, undefined> 180 settings.pluginConfigs[pluginId].options = { 181 ...nonSensitive, 182 ...scrubbed, 183 } as PluginOptionValues 184 const result = updateSettingsForSource('userSettings', settings) 185 if (result.error) { 186 logError(result.error) 187 throw new Error( 188 `Failed to save plugin options for ${pluginId}: ${result.error.message}`, 189 ) 190 } 191 } 192 193 clearPluginOptionsCache() 194} 195 196/** 197 * Delete all stored option values for a plugin — both the non-sensitive 198 * `settings.pluginConfigs[pluginId]` entry and the sensitive 199 * `secureStorage.pluginSecrets[pluginId]` entry. 200 * 201 * Call this when the LAST installation of a plugin is uninstalled (i.e., 202 * alongside `markPluginVersionOrphaned`). Don't call on every uninstall — 203 * a plugin can be installed in multiple scopes and the user's config should 204 * survive removing it from one scope while it remains in another. 205 * 206 * Best-effort: keychain write failure is logged but doesn't throw, since 207 * the uninstall itself succeeded and we don't want to surface a confusing 208 * "uninstall failed" message for a cleanup side-effect. 209 */ 210export function deletePluginOptions(pluginId: string): void { 211 // Settings side — also wipes the legacy mcpServers sub-key (same story: 212 // orphaned on uninstall, never cleaned up before this PR). 213 // 214 // Use `undefined` (not `delete`) because `updateSettingsForSource` merges 215 // via `mergeWith` — absent keys are ignored, only `undefined` triggers 216 // removal. Cast is deliberate (CLAUDE.md's 10% case): adding z.undefined() 217 // to the schema instead (like enabledPlugins:466 does) leaks 218 // `| {[k: string]: unknown}` into the public SDK type, which subsumes the 219 // real object arm and kills excess-property checks for SDK consumers. The 220 // mergeWith-deletion contract is internal plumbing — it shouldn't shape 221 // the Zod schema. enabledPlugins gets away with it only because its other 222 // arms (string[] | boolean) are non-objects that stay distinct. 223 const settings = getSettings_DEPRECATED() 224 type PluginConfigs = NonNullable<typeof settings.pluginConfigs> 225 if (settings.pluginConfigs?.[pluginId]) { 226 // Partial<Record<K,V>> = Record<K, V | undefined> — gives us the widening 227 // for the undefined value, and Partial-of-X overlaps with X so the cast 228 // is a narrowing TS accepts (same approach as marketplaceManager.ts:1795). 229 const pluginConfigs: Partial<PluginConfigs> = { [pluginId]: undefined } 230 const { error } = updateSettingsForSource('userSettings', { 231 pluginConfigs: pluginConfigs as PluginConfigs, 232 }) 233 if (error) { 234 logForDebugging( 235 `deletePluginOptions: failed to clear settings.pluginConfigs[${pluginId}]: ${error.message}`, 236 { level: 'warn' }, 237 ) 238 } 239 } 240 241 // Secure storage side — delete both the top-level pluginSecrets[pluginId] 242 // and any per-server composite keys `${pluginId}/${server}` (from 243 // saveMcpServerUserConfig's sensitive split). `/` prefix match is safe: 244 // plugin IDs are `name@marketplace`, never contain `/`, so 245 // startsWith(`${id}/`) can't false-positive on a different plugin. 246 const storage = getSecureStorage() 247 const existing = storage.read() 248 if (existing?.pluginSecrets) { 249 const prefix = `${pluginId}/` 250 const survivingEntries = Object.entries(existing.pluginSecrets).filter( 251 ([k]) => k !== pluginId && !k.startsWith(prefix), 252 ) 253 if ( 254 survivingEntries.length !== Object.keys(existing.pluginSecrets).length 255 ) { 256 const result = storage.update({ 257 ...existing, 258 pluginSecrets: 259 survivingEntries.length > 0 260 ? Object.fromEntries(survivingEntries) 261 : undefined, 262 }) 263 if (!result.success) { 264 logForDebugging( 265 `deletePluginOptions: failed to clear pluginSecrets for ${pluginId} from keychain`, 266 { level: 'warn' }, 267 ) 268 } 269 } 270 } 271 272 clearPluginOptionsCache() 273} 274 275/** 276 * Find option keys whose saved values don't satisfy the schema — i.e., what to 277 * prompt for. Returns the schema slice for those keys, or empty if everything 278 * validates. Empty manifest.userConfig → empty result. 279 * 280 * Used by PluginOptionsFlow to decide whether to show the prompt after enable. 281 */ 282export function getUnconfiguredOptions( 283 plugin: LoadedPlugin, 284): PluginOptionSchema { 285 const manifestSchema = plugin.manifest.userConfig 286 if (!manifestSchema || Object.keys(manifestSchema).length === 0) { 287 return {} 288 } 289 290 const saved = loadPluginOptions(getPluginStorageId(plugin)) 291 const validation = validateUserConfig(saved, manifestSchema) 292 if (validation.valid) { 293 return {} 294 } 295 296 // Return only the fields that failed. validateUserConfig reports errors as 297 // strings keyed by title/key — simpler to just re-check each field here than 298 // parse error strings. 299 const unconfigured: PluginOptionSchema = {} 300 for (const [key, fieldSchema] of Object.entries(manifestSchema)) { 301 const single = validateUserConfig( 302 { [key]: saved[key] } as PluginOptionValues, 303 { [key]: fieldSchema }, 304 ) 305 if (!single.valid) { 306 unconfigured[key] = fieldSchema 307 } 308 } 309 return unconfigured 310} 311 312/** 313 * Substitute ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA} with their paths. 314 * On Windows, normalizes backslashes to forward slashes so shell commands 315 * don't interpret them as escape characters. 316 * 317 * ${CLAUDE_PLUGIN_ROOT} — version-scoped install dir (recreated on update) 318 * ${CLAUDE_PLUGIN_DATA} — persistent state dir (survives updates) 319 * 320 * Both patterns use the function-replacement form of .replace(): ROOT so 321 * `$`-patterns in NTFS paths ($$, $', $`, $&) aren't interpreted; DATA so 322 * getPluginDataDir (which lazily mkdirs) only runs when actually present. 323 * 324 * Used in MCP/LSP server command/args/env, hook commands, skill/agent content. 325 */ 326export function substitutePluginVariables( 327 value: string, 328 plugin: { path: string; source?: string }, 329): string { 330 const normalize = (p: string) => 331 process.platform === 'win32' ? p.replace(/\\/g, '/') : p 332 let out = value.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => 333 normalize(plugin.path), 334 ) 335 // source can be absent (e.g. hooks where pluginRoot is a skill root without 336 // a plugin context). In that case ${CLAUDE_PLUGIN_DATA} is left literal. 337 if (plugin.source) { 338 const source = plugin.source 339 out = out.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, () => 340 normalize(getPluginDataDir(source)), 341 ) 342 } 343 return out 344} 345 346/** 347 * Substitute ${user_config.KEY} with saved option values. 348 * 349 * Throws on missing keys — callers pass this only after `validateUserConfig` 350 * succeeded, so a miss here means a plugin references a key it never declared 351 * in its schema. That's a plugin authoring bug; failing loud surfaces it. 352 * 353 * Use `substituteUserConfigInContent` for skill/agent prose — it handles 354 * missing keys and sensitive-filtering instead of throwing. 355 */ 356export function substituteUserConfigVariables( 357 value: string, 358 userConfig: PluginOptionValues, 359): string { 360 return value.replace(/\$\{user_config\.([^}]+)\}/g, (_match, key) => { 361 const configValue = userConfig[key] 362 if (configValue === undefined) { 363 throw new Error( 364 `Missing required user configuration value: ${key}. ` + 365 `This should have been validated before variable substitution.`, 366 ) 367 } 368 return String(configValue) 369 }) 370} 371 372/** 373 * Content-safe variant for skill/agent prose. Differences from 374 * `substituteUserConfigVariables`: 375 * 376 * - Sensitive-marked keys substitute to a descriptive placeholder instead of 377 * the actual value — skill/agent content goes to the model prompt, and 378 * we don't put secrets in the model's context. 379 * - Unknown keys stay literal (no throw) — matches how `${VAR}` env refs 380 * behave today when the var is unset. 381 * 382 * A ref to a sensitive key produces obvious-looking output so plugin authors 383 * notice and move the ref into a hook/MCP env instead. 384 */ 385export function substituteUserConfigInContent( 386 content: string, 387 options: PluginOptionValues, 388 schema: PluginOptionSchema, 389): string { 390 return content.replace(/\$\{user_config\.([^}]+)\}/g, (match, key) => { 391 if (schema[key]?.sensitive === true) { 392 return `[sensitive option '${key}' not available in skill content]` 393 } 394 const value = options[key] 395 if (value === undefined) { 396 return match 397 } 398 return String(value) 399 }) 400}