pluginOptionsStorage.ts
utils/plugins/pluginOptionsStorage.ts
No strong subsystem tag
401
Lines
15302
Bytes
11
Exports
8
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 401 lines, 8 detected imports, and 11 detected exports.
Important relationships
Detected exports
PluginOptionValuesPluginOptionSchemagetPluginStorageIdloadPluginOptionsclearPluginOptionsCachesavePluginOptionsdeletePluginOptionsgetUnconfiguredOptionssubstitutePluginVariablessubstituteUserConfigVariablessubstituteUserConfigInContent
Keywords
pluginsettingspluginidpluginconfigssensitivekeysobjectstoragepluginsecretsoptions
Detected imports
lodash-es/memoize.js../../types/plugin.js../debug.js../log.js../secureStorage/index.js../settings/settings.js./mcpbHandler.js./pluginDirectories.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* 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)
})
}