401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
}
|