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