170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
/**
|
|
* Legacy plugin metadata helpers shared by call sites that still assemble
|
|
* analytics-compatible payload shapes.
|
|
*
|
|
* In this fork the downstream analytics sinks are disabled, so these helpers
|
|
* only normalize/redact fields for local compatibility code; they do not
|
|
* imply an active telemetry export path.
|
|
*/
|
|
|
|
import { createHash } from 'crypto'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
} from '../../services/analytics/index.js'
|
|
import type { PluginManifest } from '../../types/plugin.js'
|
|
import {
|
|
isOfficialMarketplaceName,
|
|
parsePluginIdentifier,
|
|
} from '../plugins/pluginIdentifier.js'
|
|
|
|
// builtinPlugins.ts:BUILTIN_MARKETPLACE_NAME — inlined to avoid the cycle
|
|
// through commands.js. Marketplace schemas.ts enforces 'builtin' is reserved.
|
|
const BUILTIN_MARKETPLACE_NAME = 'builtin'
|
|
|
|
// Fixed salt for plugin_id_hash. Kept stable so legacy field shapes that still
|
|
// use this helper continue to derive the same opaque key.
|
|
const PLUGIN_ID_HASH_SALT = 'claude-plugin-telemetry-v1'
|
|
|
|
/**
|
|
* Opaque per-plugin compatibility key derived from the name@marketplace
|
|
* string. The 16-char truncation keeps the identifier short while preserving
|
|
* a stable grouping key for local compatibility code.
|
|
*/
|
|
export function hashPluginId(name: string, marketplace?: string): string {
|
|
const key = marketplace ? `${name}@${marketplace.toLowerCase()}` : name
|
|
return createHash('sha256')
|
|
.update(key + PLUGIN_ID_HASH_SALT)
|
|
.digest('hex')
|
|
.slice(0, 16)
|
|
}
|
|
|
|
/**
|
|
* 4-value scope enum for plugin origin. Distinct from PluginScope
|
|
* (managed/user/project/local) which is installation-target — this is
|
|
* marketplace-origin.
|
|
*
|
|
* - official: from an allowlisted Anthropic marketplace
|
|
* - default-bundle: ships with product (@builtin), auto-enabled
|
|
* - org: enterprise admin-pushed via managed settings (policySettings)
|
|
* - user-local: user added marketplace or local plugin
|
|
*/
|
|
export type TelemetryPluginScope =
|
|
| 'official'
|
|
| 'org'
|
|
| 'user-local'
|
|
| 'default-bundle'
|
|
|
|
export function getTelemetryPluginScope(
|
|
name: string,
|
|
marketplace: string | undefined,
|
|
managedNames: Set<string> | null,
|
|
): TelemetryPluginScope {
|
|
if (marketplace === BUILTIN_MARKETPLACE_NAME) return 'default-bundle'
|
|
if (isOfficialMarketplaceName(marketplace)) return 'official'
|
|
if (managedNames?.has(name)) return 'org'
|
|
return 'user-local'
|
|
}
|
|
|
|
/** How a skill/command invocation was triggered. */
|
|
export type InvocationTrigger =
|
|
| 'user-slash'
|
|
| 'claude-proactive'
|
|
| 'nested-skill'
|
|
|
|
/** Where a skill invocation executes. */
|
|
export type SkillExecutionContext = 'fork' | 'inline' | 'remote'
|
|
|
|
/** How a plugin install was initiated. */
|
|
export type InstallSource =
|
|
| 'cli-explicit'
|
|
| 'ui-discover'
|
|
| 'ui-suggestion'
|
|
| 'deep-link'
|
|
|
|
/**
|
|
* Common plugin metadata fields keyed off name@marketplace. Keeps the legacy
|
|
* field set in one place so no-op analytics compatibility callers do not have
|
|
* to duplicate redaction logic.
|
|
*/
|
|
export function buildPluginTelemetryFields(
|
|
name: string,
|
|
marketplace: string | undefined,
|
|
managedNames: Set<string> | null = null,
|
|
): {
|
|
plugin_id_hash: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
|
plugin_scope: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
|
plugin_name_redacted: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
|
marketplace_name_redacted: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
|
is_official_plugin: boolean
|
|
} {
|
|
const scope = getTelemetryPluginScope(name, marketplace, managedNames)
|
|
// Both official marketplaces and builtin plugins are Anthropic-controlled
|
|
// — safe to expose real names in the redacted columns.
|
|
const isAnthropicControlled =
|
|
scope === 'official' || scope === 'default-bundle'
|
|
return {
|
|
plugin_id_hash: hashPluginId(
|
|
name,
|
|
marketplace,
|
|
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
plugin_scope:
|
|
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
plugin_name_redacted: (isAnthropicControlled
|
|
? name
|
|
: 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
marketplace_name_redacted: (isAnthropicControlled && marketplace
|
|
? marketplace
|
|
: 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
is_official_plugin: isAnthropicControlled,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-invocation callers (SkillTool, processSlashCommand) pass
|
|
* managedNames=null to keep hot-path call sites free of the extra settings read.
|
|
*/
|
|
export function buildPluginCommandTelemetryFields(
|
|
pluginInfo: { pluginManifest: PluginManifest; repository: string },
|
|
managedNames: Set<string> | null = null,
|
|
): ReturnType<typeof buildPluginTelemetryFields> {
|
|
const { marketplace } = parsePluginIdentifier(pluginInfo.repository)
|
|
return buildPluginTelemetryFields(
|
|
pluginInfo.pluginManifest.name,
|
|
marketplace,
|
|
managedNames,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Stable error buckets for CLI plugin operation failures.
|
|
*/
|
|
export type PluginCommandErrorCategory =
|
|
| 'network'
|
|
| 'not-found'
|
|
| 'permission'
|
|
| 'validation'
|
|
| 'unknown'
|
|
|
|
export function classifyPluginCommandError(
|
|
error: unknown,
|
|
): PluginCommandErrorCategory {
|
|
const msg = String((error as { message?: unknown })?.message ?? error)
|
|
if (
|
|
/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|network|Could not resolve|Connection refused|timed out/i.test(
|
|
msg,
|
|
)
|
|
) {
|
|
return 'network'
|
|
}
|
|
if (/\b404\b|not found|does not exist|no such plugin/i.test(msg)) {
|
|
return 'not-found'
|
|
}
|
|
if (/\b40[13]\b|EACCES|EPERM|permission denied|unauthorized/i.test(msg)) {
|
|
return 'permission'
|
|
}
|
|
if (/invalid|malformed|schema|validation|parse error/i.test(msg)) {
|
|
return 'validation'
|
|
}
|
|
return 'unknown'
|
|
}
|