/ utils / plugins / pluginOptionsStorage.ts
pluginOptionsStorage.ts
  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  
 15  import memoize from 'lodash-es/memoize.js'
 16  import type { LoadedPlugin } from '../../types/plugin.js'
 17  import { logForDebugging } from '../debug.js'
 18  import { logError } from '../log.js'
 19  import { getSecureStorage } from '../secureStorage/index.js'
 20  import {
 21    getSettings_DEPRECATED,
 22    updateSettingsForSource,
 23  } from '../settings/settings.js'
 24  import {
 25    type UserConfigSchema,
 26    type UserConfigValues,
 27    validateUserConfig,
 28  } from './mcpbHandler.js'
 29  import { getPluginDataDir } from './pluginDirectories.js'
 30  
 31  export type PluginOptionValues = UserConfigValues
 32  export 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   */
 44  export 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   */
 56  export 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  
 79  export 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   */
 90  export 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   */
210  export 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   */
282  export 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   */
326  export 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   */
356  export 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   */
385  export 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  }