// Detects plugin version drift between config, manifests, and installs. import type { OpenClawConfig } from "../config/types.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { resolveTrustedSourceLinkedOfficialClawHubInstall, resolveTrustedSourceLinkedOfficialNpmSpec, } from "./official-external-install-records.js"; export type PluginVersionDriftEntry = { pluginId: string; installedVersion: string; gatewayVersion: string; source: PluginInstallRecord["source"]; packageName?: string; spec?: string; }; export type PluginVersionDriftReport = { gatewayVersion: string; drifts: PluginVersionDriftEntry[]; }; /** * Strip a trailing build qualifier (e.g. `2026.5.4-1` -> `2026.5.4`) so that * a gateway packaged as `2026.5.4-1` is not reported as drifted from a * plugin packaged as `2026.5.4`. Both ends are normalized identically. */ function normalizeVersion(value: string): string { return value.replace(/-\d+$/, ""); } function isPluginEnabled(config: OpenClawConfig | undefined, pluginId: string): boolean { const normalizedPluginConfig = normalizePluginsConfig(config?.plugins); return resolveEffectiveEnableState({ id: pluginId, origin: "global", config: normalizedPluginConfig, rootConfig: config, }).enabled; } function shouldCompareOfficialInstallToGateway(params: { pluginId: string; record: PluginInstallRecord; }): boolean { const officialNpmSpec = resolveTrustedSourceLinkedOfficialNpmSpec(params); if (officialNpmSpec) { return parseRegistryNpmSpec(officialNpmSpec)?.selectorKind !== "exact-version"; } const officialClawHubInstall = resolveTrustedSourceLinkedOfficialClawHubInstall(params); if (officialClawHubInstall) { if (officialClawHubInstall.clawhubSpec) { return !parseClawHubPluginSpec(officialClawHubInstall.clawhubSpec)?.version; } return ( parseRegistryNpmSpec(officialClawHubInstall.npmSpec ?? "")?.selectorKind !== "exact-version" ); } return false; } /** * Compare active official external plugin installs against the running gateway * version and return any mismatches. * * @param params.gatewayVersion The gateway version string (typically the * `version` field of the installed openclaw package.json). * @param params.installRecords The full set of recorded plugin installs (as * produced by `loadInstalledPluginIndexInstallRecords`). * @param params.config The merged daemon-side OpenClawConfig (optional). * Plugins inactive under the effective activation policy are skipped. * * The returned `drifts` list is sorted by `pluginId` for stable output. */ export function detectPluginVersionDrift(params: { gatewayVersion: string; installRecords: Record; config?: OpenClawConfig; }): PluginVersionDriftReport { const { gatewayVersion, installRecords, config } = params; const normalizedGateway = normalizeVersion(gatewayVersion); const drifts: PluginVersionDriftEntry[] = []; for (const [pluginId, record] of Object.entries(installRecords)) { if (!record) { continue; } if (!isPluginEnabled(config, pluginId)) { continue; } if ( !shouldCompareOfficialInstallToGateway({ pluginId, record, }) ) { continue; } const installedVersion = record.resolvedVersion ?? record.version; if (!installedVersion) { // No version recorded for this install — nothing to compare against. // Don't fabricate drift; surface tooling (status.print) can flag this // separately if desired. continue; } if (normalizeVersion(installedVersion) === normalizedGateway) { continue; } drifts.push({ pluginId, installedVersion, gatewayVersion, source: record.source, ...(record.resolvedName ? { packageName: record.resolvedName } : {}), ...(record.spec ? { spec: record.spec } : {}), }); } drifts.sort((a, b) => a.pluginId.localeCompare(b.pluginId)); return { gatewayVersion, drifts, }; }