/** Updates installed plugins across npm, ClawHub, marketplace, Git, and bundled bridge sources. */ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { satisfiesPluginApiRange } from "../infra/clawhub.js"; import { unscopedPackageName } from "../infra/install-safe-path.js"; import type { NpmSpecResolution } from "../infra/install-source-utils.js"; import { createNpmMetadataEnv, resolveNpmSpecMetadata } from "../infra/install-source-utils.js"; import { compareOpenClawReleaseVersions, isExactSemverVersion, isOpenClawOrgNpmSpec, isPrereleaseSemverVersion, isPrereleaseResolutionAllowed, parseRegistryNpmSpec, } from "../infra/npm-registry-spec.js"; import { expectedIntegrityForUpdate, installedPackageNeedsOpenClawPeerLinkRepair, readInstalledPackagePeerDependencies, readInstalledPackageVersion, } from "../infra/package-update-utils.js"; import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js"; import type { UpdateChannel } from "../infra/update-channels.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js"; import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { getExternalizedBundledPluginLegacyPathSuffix, getExternalizedBundledPluginClawHubSpec, getExternalizedBundledPluginLookupIds, getExternalizedBundledPluginNpmSpec, getExternalizedBundledPluginPreferredSource, getExternalizedBundledPluginTargetId, type ExternalizedBundledPluginBridge, } from "./externalized-bundled-plugins.js"; import { installPluginFromGitSpec } from "./git-install.js"; import { resolveClawHubInstallSpecsForUpdateChannel, resolveNpmInstallSpecsForUpdateChannel, } from "./install-channel-specs.js"; import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE, resolvePluginInstallDir, } from "./install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall, resolveNpmInstallRecordSpec, } from "./installs.js"; import { installPluginFromMarketplace } from "./marketplace.js"; import { checkMinHostVersion } from "./min-host-version.js"; import { resolveTrustedSourceLinkedOfficialClawHubSpec, resolveTrustedSourceLinkedOfficialNpmSpec, } from "./official-external-install-records.js"; import { getOfficialExternalPluginCatalogEntry, resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; import { resolvePackagePluginApiRange } from "./package-compat.js"; import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js"; import { defaultSlotIdForKey } from "./slots.js"; /** Logger surface used by plugin update flows. */ export type PluginUpdateLogger = { info?: (message: string) => void; warn?: (message: string) => void; error?: (message: string) => void; }; /** Outcome status for one plugin update attempt. */ export type PluginUpdateStatus = "updated" | "unchanged" | "skipped" | "error"; export type PluginUpdateChannelFallback = { requestedSpec: string; usedSpec: string; requestedLabel: string; usedLabel: string; reason: "unavailable" | "failed"; message: string; }; export type PluginUpdateOutcome = { pluginId: string; status: PluginUpdateStatus; message: string; currentVersion?: string; nextVersion?: string; channelFallback?: PluginUpdateChannelFallback; }; export type PluginUpdateSummary = { config: OpenClawConfig; changed: boolean; outcomes: PluginUpdateOutcome[]; }; export type PluginUpdateIntegrityDriftParams = { pluginId: string; spec: string; expectedIntegrity: string; actualIntegrity: string; resolvedSpec?: string; resolvedVersion?: string; dryRun: boolean; }; export type PluginChannelSyncSummary = { switchedToBundled: string[]; switchedToClawHub: string[]; switchedToNpm: string[]; warnings: string[]; errors: string[]; }; export type PluginChannelSyncResult = { config: OpenClawConfig; changed: boolean; summary: PluginChannelSyncSummary; }; /** Return whether a tracked plugin install source can be updated in place. */ export function isPluginInstallRecordUpdateSource( record: PluginInstallRecord | undefined, ): boolean { return ( record?.source === "npm" || record?.source === "marketplace" || record?.source === "clawhub" || record?.source === "git" ); } /** Return whether update identity compatibility can migrate an unscoped install key. */ export function pluginInstallRecordMayMigrateConfigId(params: { pluginId: string; record: PluginInstallRecord | undefined; specOverride?: string; }): boolean { if (!isPluginInstallRecordUpdateSource(params.record)) { return false; } if (params.record?.source !== "npm") { // Generic package/archive installers can resolve an unscoped tracked key // to a scoped package id; the exact package identity is unavailable preflight. return !params.pluginId.includes("/"); } const packageName = resolveNpmSpecPackageName(params.specOverride ?? params.record.spec) ?? params.record.resolvedName ?? resolveNpmSpecPackageName(params.record.resolvedSpec); return Boolean( packageName && packageName !== params.pluginId && unscopedPackageName(packageName) === params.pluginId, ); } function formatNpmInstallFailure(params: { pluginId: string; spec: string; phase: "check" | "update"; result: { error: string; code?: string }; }): string { if (params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND) { return `Failed to ${params.phase} ${params.pluginId}: npm package not found for ${params.spec}.`; } return `Failed to ${params.phase} ${params.pluginId}: ${params.result.error}`; } function formatMarketplaceInstallFailure(params: { pluginId: string; marketplaceSource: string; marketplacePlugin: string; phase: "check" | "update"; error: string; }): string { return ( `Failed to ${params.phase} ${params.pluginId}: ` + `${params.error} (marketplace plugin ${params.marketplacePlugin} from ${params.marketplaceSource}).` ); } function formatClawHubInstallFailure(params: { pluginId: string; spec: string; phase: "check" | "update"; error: string; }): string { return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (ClawHub ${params.spec}).`; } function formatGitInstallFailure(params: { pluginId: string; spec: string; phase: "check" | "update"; error: string; }): string { return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (git ${params.spec}).`; } type InstallIntegrityDrift = { spec: string; expectedIntegrity: string; actualIntegrity: string; resolution: { resolvedSpec?: string; version?: string; }; }; function shouldSkipUnchangedNpmInstall(params: { currentVersion?: string; record: { integrity?: string; shasum?: string; resolvedName?: string; resolvedSpec?: string; resolvedVersion?: string; }; metadata: NpmSpecResolution; }): boolean { if (!params.currentVersion || !params.metadata.version) { return false; } if (params.currentVersion !== params.metadata.version) { return false; } if ( !params.record.resolvedName || !params.record.resolvedSpec || !params.record.resolvedVersion ) { return false; } if (!params.metadata.name || !params.metadata.resolvedSpec) { return false; } if (params.metadata.integrity && !params.record.integrity) { return false; } if (params.metadata.shasum && !params.record.shasum) { return false; } return ( (!params.metadata.integrity || params.record.integrity === params.metadata.integrity) && (!params.metadata.shasum || params.record.shasum === params.metadata.shasum) && params.record.resolvedName === params.metadata.name && params.record.resolvedSpec === params.metadata.resolvedSpec && params.record.resolvedVersion === params.metadata.version ); } function shouldBypassTrustedOfficialUnchangedNpmCheck(params: { metadata: NpmSpecResolution; spec: string; trustedSourceLinkedOfficialInstall: boolean; }): boolean { if (!params.trustedSourceLinkedOfficialInstall || !params.metadata.version) { return false; } const parsedSpec = parseRegistryNpmSpec(params.spec); return Boolean( parsedSpec && !isPrereleaseResolutionAllowed({ spec: parsedSpec, resolvedVersion: params.metadata.version, }), ); } function expectedIntegrityForNpmUpdate(params: { effectiveSpec: string | undefined; metadata?: NpmSpecResolution; record: PluginInstallRecord; trustedSourceLinkedOfficialInstall: boolean; }): string | undefined { if (params.record.source !== "npm") { return undefined; } if (params.effectiveSpec === params.record.spec) { return expectedIntegrityForUpdate(params.record.spec, params.record.integrity); } if (!params.trustedSourceLinkedOfficialInstall || !params.metadata) { return undefined; } const metadataName = params.metadata.name ?? resolveNpmSpecPackageName(params.effectiveSpec); const recordName = params.record.resolvedName ?? resolveNpmSpecPackageName(params.record.resolvedSpec) ?? resolveNpmSpecPackageName(params.record.spec); if (!metadataName || metadataName !== recordName) { return undefined; } if (!params.metadata.version || params.metadata.version !== params.record.resolvedVersion) { return undefined; } return expectedIntegrityForUpdate( params.record.resolvedSpec ?? params.record.spec, params.record.integrity, ); } function compareNpmSemverForUpdate(left: string, right: string): number { const releaseCmp = compareOpenClawReleaseVersions(left, right); if (releaseCmp !== null) { return releaseCmp; } return compareComparableSemver(parseComparableSemver(left), parseComparableSemver(right)) ?? 0; } async function loadNpmPackageVersionsForUpdate(params: { packageName: string; timeoutMs?: number; }): Promise { const versions = await runCommandWithTimeout( ["npm", "view", params.packageName, "versions", "--json"], { timeoutMs: Math.max(params.timeoutMs ?? 0, 60_000), env: createNpmMetadataEnv(), }, ); if (!versions || versions.code !== 0) { return null; } let parsed: unknown; try { parsed = JSON.parse(versions.stdout.trim()); } catch { return null; } return (Array.isArray(parsed) ? parsed : [parsed]).filter( (value): value is string => typeof value === "string" && isExactSemverVersion(value), ); } async function resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate(params: { metadata: NpmSpecResolution; spec: string; timeoutMs?: number; }): Promise< | { kind: "stable" | "prerelease-only"; metadata: NpmSpecResolution; } | undefined > { const parsedSpec = parseRegistryNpmSpec(params.spec); if ( !parsedSpec || !parsedSpec.name.startsWith("@openclaw/") || !params.metadata.version || isPrereleaseResolutionAllowed({ spec: parsedSpec, resolvedVersion: params.metadata.version, }) ) { return undefined; } const versions = await loadNpmPackageVersionsForUpdate({ packageName: parsedSpec.name, timeoutMs: params.timeoutMs, }); const stableVersion = versions ?.filter((value) => !isPrereleaseSemverVersion(value)) .toSorted(compareNpmSemverForUpdate) .at(-1); if (stableVersion) { const stableMetadata = await resolveNpmSpecMetadata({ spec: `${parsedSpec.name}@${stableVersion}`, timeoutMs: params.timeoutMs, }); return stableMetadata.ok ? { kind: "stable", metadata: stableMetadata.metadata } : undefined; } const prereleaseVersion = versions ?.filter(isPrereleaseSemverVersion) .toSorted(compareNpmSemverForUpdate) .at(-1); if (!prereleaseVersion || !versions?.every(isPrereleaseSemverVersion)) { return undefined; } if (prereleaseVersion === params.metadata.version) { return { kind: "prerelease-only", metadata: params.metadata }; } const prereleaseMetadata = await resolveNpmSpecMetadata({ spec: `${parsedSpec.name}@${prereleaseVersion}`, timeoutMs: params.timeoutMs, }); return prereleaseMetadata.ok ? { kind: "prerelease-only", metadata: prereleaseMetadata.metadata } : undefined; } async function expectedIntegrityForNpmFallback(params: { fallbackSpec: string | undefined; record: PluginInstallRecord; timeoutMs?: number; trustedSourceLinkedOfficialInstall: boolean; }): Promise { if (params.record.source !== "npm" || !params.fallbackSpec) { return undefined; } if (params.fallbackSpec === params.record.spec) { return expectedIntegrityForUpdate(params.record.spec, params.record.integrity); } if (!params.trustedSourceLinkedOfficialInstall) { return undefined; } const fallbackMetadata = await resolveNpmSpecMetadata({ spec: params.fallbackSpec, timeoutMs: params.timeoutMs, }); if (!fallbackMetadata.ok) { return undefined; } const trustedPrereleaseFallback = await resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate( { metadata: fallbackMetadata.metadata, spec: params.fallbackSpec, timeoutMs: params.timeoutMs, }, ); const expectedIntegrityMetadata = trustedPrereleaseFallback?.metadata ?? fallbackMetadata.metadata; if (!isNpmMetadataCompatibleWithCurrentHost(expectedIntegrityMetadata)) { return undefined; } return expectedIntegrityForNpmUpdate({ effectiveSpec: params.fallbackSpec, metadata: expectedIntegrityMetadata, record: params.record, trustedSourceLinkedOfficialInstall: true, }); } function isNpmMetadataCompatibleWithCurrentHost(metadata: NpmSpecResolution): boolean { const hostVersion = resolveCompatibilityHostVersion(); const installMetadata = metadata.packageOpenClaw?.install; const minHostVersionCheck = checkMinHostVersion({ currentVersion: hostVersion, minHostVersion: isRecord(installMetadata) ? installMetadata.minHostVersion : undefined, }); if (!minHostVersionCheck.ok) { return false; } const pluginApiRangeCheck = resolvePackagePluginApiRange(metadata.packageOpenClaw); if (!pluginApiRangeCheck.ok) { return false; } const pluginApiRange = pluginApiRangeCheck.range; if (!pluginApiRange) { return true; } return satisfiesPluginApiRange(hostVersion, pluginApiRange); } function isBundledVersionNewer(bundledVersion: string, installedVersion: string): boolean { const releaseCmp = compareOpenClawReleaseVersions(bundledVersion, installedVersion); if (releaseCmp !== null) { return releaseCmp > 0; } const bundled = parseComparableSemver(bundledVersion); const installed = parseComparableSemver(installedVersion); const cmp = compareComparableSemver(bundled, installed); return cmp !== null && cmp > 0; } function pathsEqual( left: string | undefined, right: string | undefined, env: NodeJS.ProcessEnv = process.env, ): boolean { if (!left || !right) { return false; } return resolveUserPath(left, env) === resolveUserPath(right, env); } function resolveRecordedExtensionsDir(params: { pluginId: string; installPath: string; }): string | undefined { const parentDir = path.dirname(params.installPath); try { const canonicalInstallPath = resolvePluginInstallDir(params.pluginId, parentDir); return canonicalInstallPath === params.installPath ? parentDir : undefined; } catch { return undefined; } } function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = process.env) { let paths = [...existing]; const resolveSet = () => new Set(paths.map((entry) => resolveUserPath(entry, env))); let resolved = resolveSet(); let changed = false; const addPath = (value: string) => { const normalized = resolveUserPath(value, env); if (resolved.has(normalized)) { return; } paths.push(value); resolved.add(normalized); changed = true; }; const removePath = (value: string) => { const normalized = resolveUserPath(value, env); if (!resolved.has(normalized)) { return; } paths = paths.filter((entry) => resolveUserPath(entry, env) !== normalized); resolved = resolveSet(); changed = true; }; const removeMatching = (predicate: (value: string) => boolean) => { const next = paths.filter((entry) => !predicate(entry)); if (next.length === paths.length) { return; } paths = next; resolved = resolveSet(); changed = true; }; return { addPath, removePath, removeMatching, get changed() { return changed; }, get paths() { return paths; }, }; } function normalizePathSegment(value: string | undefined): string { return ( value ?.trim() .replaceAll("\\", "/") .replace(/^\/+|\/+$/g, "") ?? "" ); } function pathEndsWithSegment(params: { value: string | undefined; segment: string | undefined; env: NodeJS.ProcessEnv; }): boolean { const value = normalizePathSegment(params.value ? resolveUserPath(params.value, params.env) : ""); const segment = normalizePathSegment(params.segment); return Boolean(value && segment && (value === segment || value.endsWith(`/${segment}`))); } function isBridgeBundledPathRecord(params: { bridge: ExternalizedBundledPluginBridge; bundledLocalPath?: string; record: PluginInstallRecord; env: NodeJS.ProcessEnv; }): boolean { if (params.record.source !== "path") { return false; } if ( params.bundledLocalPath && (pathsEqual(params.record.sourcePath, params.bundledLocalPath, params.env) || pathsEqual(params.record.installPath, params.bundledLocalPath, params.env)) ) { return true; } const bundledPathSuffix = getExternalizedBundledPluginLegacyPathSuffix(params.bridge); return ( pathEndsWithSegment({ value: params.record.sourcePath, segment: bundledPathSuffix, env: params.env, }) || pathEndsWithSegment({ value: params.record.installPath, segment: bundledPathSuffix, env: params.env, }) ); } function removeBridgeBundledLoadPaths(params: { bridge: ExternalizedBundledPluginBridge; loadPaths: ReturnType; env: NodeJS.ProcessEnv; }) { const bundledPathSuffix = getExternalizedBundledPluginLegacyPathSuffix(params.bridge); params.loadPaths.removeMatching((entry) => pathEndsWithSegment({ value: entry, segment: bundledPathSuffix, env: params.env, }), ); } function resolveBridgeInstallRecord(params: { installs: Record; bridge: ExternalizedBundledPluginBridge; }): { pluginId: string; record: PluginInstallRecord } | undefined { for (const pluginId of getExternalizedBundledPluginLookupIds(params.bridge)) { const record = params.installs[pluginId]; if (record) { return { pluginId, record }; } } return undefined; } function isBridgeChannelEnabledByConfig(params: { config: OpenClawConfig; bridge: ExternalizedBundledPluginBridge; }): boolean { const channels = params.config.channels; if (!channels || typeof channels !== "object" || Array.isArray(channels)) { return false; } for (const channelId of params.bridge.channelIds ?? []) { const entry = (channels as Record)[channelId]; if (!entry || typeof entry !== "object" || Array.isArray(entry)) { continue; } if (Object.is((entry as Record).enabled, true)) { return true; } } return false; } function isExternalizedBundledPluginEnabled(params: { config: OpenClawConfig; bridge: ExternalizedBundledPluginBridge; }): boolean { const normalized = normalizePluginsConfig(params.config.plugins); if (!normalized.enabled) { return false; } const pluginIds = getExternalizedBundledPluginLookupIds(params.bridge); if ( pluginIds.some( (pluginId) => normalized.deny.includes(pluginId) || Object.is(normalized.entries[pluginId]?.enabled, false), ) ) { return false; } for (const pluginId of pluginIds) { if ( resolveEffectiveEnableState({ id: pluginId, origin: "bundled", config: normalized, rootConfig: params.config, enabledByDefault: params.bridge.enabledByDefault, }).enabled ) { return true; } } if (isBridgeChannelEnabledByConfig(params)) { return true; } return false; } function shouldFallbackClawHubBridgeToNpm(params: { result: { ok: false; code?: string }; npmSpec?: string; }): boolean { if (!isOpenClawOrgNpmSpec(params.npmSpec)) { return false; } return ( params.result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND || params.result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND || params.result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE || params.result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE ); } function shouldFallbackClawHubToDefault(result: { ok: false; code?: string }): boolean { return ( result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND || result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND ); } function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): boolean { return shouldFallbackClawHubToDefault(result); } function isUnavailableNpmTarget(result: { ok: false; code?: string; error: string }): boolean { return ( result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND || /\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(result.error) ); } function describeBetaNpmFallback(params: { pluginId: string; betaSpec: string | undefined; fallbackSpec: string; result: { ok: false; code?: string; error: string }; }): string { const betaSpec = params.betaSpec ?? "the beta npm release"; const missingBeta = isUnavailableNpmTarget(params.result); const reason = missingBeta ? "has no beta npm release" : "failed beta npm update"; return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; using ${params.fallbackSpec} instead. Core update can still complete.`; } function formatNpmSpecSelectorLabel(spec: string | undefined): string { const parsed = spec ? parseRegistryNpmSpec(spec) : undefined; if (!parsed) { return spec ?? "unknown"; } if (parsed.selectorKind === "none") { return "@latest"; } return `@${parsed.selector}`; } function describeNpmChannelFallback(params: { pluginId: string; requestedSpec: string | undefined; usedSpec: string; result: { ok: false; code?: string; error: string }; verb: "used" | "would use"; }): PluginUpdateChannelFallback { const requestedSpec = params.requestedSpec ?? "unknown"; const requestedLabel = formatNpmSpecSelectorLabel(params.requestedSpec); const usedLabel = formatNpmSpecSelectorLabel(params.usedSpec); const reason = isUnavailableNpmTarget(params.result) ? "unavailable" : "failed"; const message = reason === "unavailable" ? `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} because ${requestedLabel} was unavailable` : `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} after ${requestedLabel} failed`; return { requestedSpec, usedSpec: params.usedSpec, requestedLabel, usedLabel, reason, message, }; } function formatBetaChannelFallbackOutcomeSuffix(params: { fallbackLabel: string | undefined; fallbackSpec: string | undefined; verb: "used" | "would use"; }): string { if (!params.fallbackSpec) { return ""; } const betaTarget = params.fallbackLabel ?? "beta target"; return ` (warning: beta channel fallback ${params.verb} ${params.fallbackSpec} because ${betaTarget} could not be used).`; } function npmUpdateFailureSpec(params: { effectiveSpec: string | undefined; fallbackSpec: string | undefined; usedFallback: boolean; }): string { if (params.usedFallback && params.fallbackSpec) { return params.fallbackSpec; } return params.effectiveSpec ?? params.fallbackSpec ?? "unknown"; } function resolveNpmSpecPackageName(spec: string | undefined): string | undefined { return spec ? parseRegistryNpmSpec(spec)?.name : undefined; } function resolveExactNpmSpecVersion(spec: string | undefined): string | undefined { const parsed = spec ? parseRegistryNpmSpec(spec) : null; return parsed?.selectorKind === "exact-version" ? parsed.selector : undefined; } function resolveNpmResultVersion(result: { npmResolution?: NpmSpecResolution; }): string | undefined { return result.npmResolution?.version; } function resolveClawHubSpecPackageName(spec: string | undefined): string | undefined { return spec ? parseClawHubPluginSpec(spec)?.name : undefined; } function isOfficialClawHubInstallRecord(record: PluginInstallRecord): boolean { if (record.source !== "clawhub" || record.clawhubChannel !== "official") { return false; } return (record.clawhubUrl ?? "").replace(/\/+$/, "") === "https://clawhub.ai"; } function resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate(params: { pluginId: string; record: PluginInstallRecord; effectiveClawHubSpec?: string; recordClawHubSpec?: string; updateChannel?: UpdateChannel; }): { installSpec: string; recordSpec: string; fallbackSpec?: string; fallbackLabel?: string; } | null { if (!isOfficialClawHubInstallRecord(params.record)) { return null; } const entry = getOfficialExternalPluginCatalogEntry(params.pluginId); if (!entry) { return null; } const officialSpec = resolveOfficialExternalPluginInstall(entry)?.npmSpec; const officialPackageName = resolveNpmSpecPackageName(officialSpec); if (!officialSpec || !officialPackageName) { return null; } const recordedPackageNames = [ params.record.clawhubPackage, resolveClawHubSpecPackageName(params.record.spec), resolveClawHubSpecPackageName(params.effectiveClawHubSpec), ].filter((value): value is string => Boolean(value)); if (!recordedPackageNames.includes(officialPackageName)) { return null; } const effectiveClawHubVersion = params.effectiveClawHubSpec ? parseClawHubPluginSpec(params.effectiveClawHubSpec)?.version : undefined; const recordClawHubVersion = params.recordClawHubSpec ? parseClawHubPluginSpec(params.recordClawHubSpec)?.version : undefined; if (effectiveClawHubVersion && effectiveClawHubVersion.toLowerCase() !== "latest") { return { installSpec: `${officialPackageName}@${effectiveClawHubVersion}`, recordSpec: recordClawHubVersion && recordClawHubVersion.toLowerCase() !== "latest" ? `${officialPackageName}@${recordClawHubVersion}` : officialSpec, ...(params.updateChannel === "beta" && effectiveClawHubVersion.toLowerCase() === "beta" ? { fallbackSpec: officialSpec, fallbackLabel: `${officialPackageName}@beta` } : {}), }; } return resolveNpmInstallSpecsForUpdateChannel({ spec: officialSpec, updateChannel: params.updateChannel, }); } function isTrustedSourceLinkedOfficialNpmUpdate(params: { pluginId: string; spec: string | undefined; record: PluginInstallRecord; }): boolean { const officialSpec = resolveTrustedSourceLinkedOfficialNpmSpec(params); const officialPackageName = resolveNpmSpecPackageName(officialSpec); const requestedPackageName = resolveNpmSpecPackageName(params.spec); return Boolean(officialPackageName && requestedPackageName === officialPackageName); } function isTrustedSourceLinkedOfficialBridgeNpmInstall(params: { targetPluginId: string; npmSpec: string | undefined; }): boolean { const entry = getOfficialExternalPluginCatalogEntry(params.targetPluginId); if (!entry) { return false; } const officialPackageName = resolveNpmSpecPackageName( resolveOfficialExternalPluginInstall(entry)?.npmSpec, ); const requestedPackageName = resolveNpmSpecPackageName(params.npmSpec); return Boolean(officialPackageName && requestedPackageName === officialPackageName); } function isBridgeNpmInstall(params: { bridge: ExternalizedBundledPluginBridge; record: PluginInstallRecord; }): boolean { const npmSpec = getExternalizedBundledPluginNpmSpec(params.bridge); if (!npmSpec || params.record.source !== "npm") { return false; } const bridgePackageName = resolveNpmSpecPackageName(npmSpec); const recordPackageName = params.record.resolvedName ?? resolveNpmSpecPackageName(params.record.spec) ?? resolveNpmSpecPackageName(params.record.resolvedSpec); return Boolean(bridgePackageName && recordPackageName === bridgePackageName); } function isBridgeClawHubInstall(params: { bridge: ExternalizedBundledPluginBridge; record: PluginInstallRecord; }): boolean { if (params.record.source !== "clawhub") { return false; } const clawhubSpec = getExternalizedBundledPluginClawHubSpec(params.bridge); const bridgeClawHubPackage = clawhubSpec ? parseClawHubPluginSpec(clawhubSpec)?.name : undefined; const recordClawHubPackage = params.record.clawhubPackage ?? parseClawHubPluginSpec(params.record.spec ?? "")?.name; return Boolean(bridgeClawHubPackage && recordClawHubPackage === bridgeClawHubPackage); } function resolveNpmUpdateSpecs(params: { record: PluginInstallRecord; specOverride?: string; officialSpecOverride?: string; updateChannel?: UpdateChannel; }): { installSpec?: string; recordSpec?: string; fallbackSpec?: string; fallbackLabel?: string; } { const recordSpec = params.specOverride ?? params.officialSpecOverride ?? params.record.spec; if (!recordSpec) { return {}; } if (params.specOverride) { return { installSpec: recordSpec, recordSpec, }; } return resolveNpmInstallSpecsForUpdateChannel({ spec: recordSpec, updateChannel: params.updateChannel, }); } function resolveClawHubUpdateSpecs(params: { record: PluginInstallRecord; officialSpecOverride?: string; updateChannel?: UpdateChannel; }): { installSpec?: string; recordSpec?: string; fallbackSpec?: string; fallbackLabel?: string; } { if (!params.officialSpecOverride && !params.record.clawhubPackage) { return {}; } const recordSpec = params.officialSpecOverride ?? params.record.spec ?? `clawhub:${params.record.clawhubPackage}`; return resolveClawHubInstallSpecsForUpdateChannel({ spec: recordSpec, updateChannel: params.updateChannel, }); } function isBridgeAlreadyInstalledFromPreferredSource(params: { bridge: ExternalizedBundledPluginBridge; record: PluginInstallRecord; }): boolean { const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge); return preferredSource === "clawhub" ? isBridgeClawHubInstall(params) : isBridgeNpmInstall(params); } function isBridgeInstalledFromFallbackSource(params: { bridge: ExternalizedBundledPluginBridge; record: PluginInstallRecord; }): boolean { const preferredSource = getExternalizedBundledPluginPreferredSource(params.bridge); return preferredSource === "clawhub" ? isBridgeNpmInstall(params) : isBridgeClawHubInstall(params); } function replacePluginIdInList( entries: string[] | undefined, fromId: string, toId: string, ): string[] | undefined { if (!entries || entries.length === 0 || fromId === toId || !entries.includes(fromId)) { return entries; } const next: string[] = []; for (const entry of entries) { const value = entry === fromId ? toId : entry; if (!next.includes(value)) { next.push(value); } } return next; } function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string): OpenClawConfig { const plugins = cfg.plugins; if (fromId === toId || !plugins) { return cfg; } let nextPlugins = plugins; const ensureNextPlugins = () => { if (nextPlugins === plugins) { nextPlugins = { ...plugins }; } return nextPlugins; }; const installs = plugins.installs; if (installs && Object.hasOwn(installs, fromId)) { const nextInstalls = { ...installs }; const record = nextInstalls[fromId]; if (record && !(toId in nextInstalls)) { nextInstalls[toId] = record; } delete nextInstalls[fromId]; ensureNextPlugins().installs = nextInstalls; } const entries = plugins.entries; if (entries && Object.hasOwn(entries, fromId)) { const nextEntries = { ...entries }; const entry = nextEntries[fromId]; if (entry) { nextEntries[toId] = nextEntries[toId] ? { ...entry, ...nextEntries[toId], } : entry; } delete nextEntries[fromId]; ensureNextPlugins().entries = nextEntries; } const allow = replacePluginIdInList(plugins.allow, fromId, toId); if (allow !== plugins.allow) { ensureNextPlugins().allow = allow; } const deny = replacePluginIdInList(plugins.deny, fromId, toId); if (deny !== plugins.deny) { ensureNextPlugins().deny = deny; } const slots = plugins.slots; if (slots?.memory === fromId || slots?.contextEngine === fromId) { ensureNextPlugins().slots = { ...slots, ...(slots.memory === fromId ? { memory: toId } : {}), ...(slots.contextEngine === fromId ? { contextEngine: toId } : {}), }; } return nextPlugins === plugins ? cfg : { ...cfg, plugins: nextPlugins }; } function withoutPluginInstallRecord(cfg: OpenClawConfig, pluginId: string): OpenClawConfig { const installs = cfg.plugins?.installs; if (!installs || !Object.hasOwn(installs, pluginId)) { return cfg; } const { [pluginId]: _removed, ...nextInstalls } = installs; return { ...cfg, plugins: { ...cfg.plugins, installs: nextInstalls, }, }; } function createPluginUpdateIntegrityDriftHandler(params: { pluginId: string; dryRun: boolean; logger: PluginUpdateLogger; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; }) { return async (drift: InstallIntegrityDrift) => { const payload: PluginUpdateIntegrityDriftParams = { pluginId: params.pluginId, spec: drift.spec, expectedIntegrity: drift.expectedIntegrity, actualIntegrity: drift.actualIntegrity, resolvedSpec: drift.resolution.resolvedSpec, resolvedVersion: drift.resolution.version, dryRun: params.dryRun, }; if (params.onIntegrityDrift) { return await params.onIntegrityDrift(payload); } params.logger.warn?.( `Integrity drift for "${params.pluginId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`, ); return false; }; } function removeDisabledPluginIdFromList( list: string[] | undefined, pluginId: string, ): string[] | undefined { if (!Array.isArray(list) || !list.includes(pluginId)) { return list; } const next = list.filter((id) => id !== pluginId); return next.length > 0 ? next : undefined; } function resetDisabledPluginSlots( slots: NonNullable["slots"] | undefined, pluginId: string, ): NonNullable["slots"] | undefined { if (!slots) { return slots; } let next = slots; if (next.memory === pluginId) { next = { ...next, memory: defaultSlotIdForKey("memory"), }; } if (next.contextEngine === pluginId) { next = { ...next, contextEngine: defaultSlotIdForKey("contextEngine"), }; } return next; } function disablePluginConfigEntry(config: OpenClawConfig, pluginId: string): OpenClawConfig { const pluginsConfig = config.plugins ?? {}; const existingEntry = pluginsConfig.entries?.[pluginId]; return { ...config, plugins: { ...pluginsConfig, allow: removeDisabledPluginIdFromList(pluginsConfig.allow, pluginId), deny: removeDisabledPluginIdFromList(pluginsConfig.deny, pluginId), slots: resetDisabledPluginSlots(pluginsConfig.slots, pluginId), entries: { ...pluginsConfig.entries, [pluginId]: { ...existingEntry, enabled: false, }, }, }, }; } async function repairOpenClawPeerLinksForNpmInstalls(params: { config: OpenClawConfig; logger: PluginUpdateLogger; }): Promise { let repaired = false; for (const [pluginId, record] of Object.entries(params.config.plugins?.installs ?? {})) { if (record.source !== "npm") { continue; } let installPath: string; try { installPath = resolveUserPath( record.installPath?.trim() || resolvePluginInstallDir(pluginId), ); } catch (err) { params.logger.warn?.( `Could not repair openclaw peer link for "${pluginId}" due to invalid install path: ${String(err)}`, ); continue; } if (!installedPackageNeedsOpenClawPeerLinkRepair(installPath)) { continue; } const peerDependencies = readInstalledPackagePeerDependencies(installPath); if (!Object.hasOwn(peerDependencies, "openclaw")) { continue; } try { const warnings: string[] = []; const peerLinkRepair = await linkOpenClawPeerDependencies({ installedDir: installPath, peerDependencies, logger: { info: (message) => params.logger.info?.(message), warn: (message) => warnings.push(message), }, }); if (peerLinkRepair.skipped > 0) { params.logger.warn?.( `Could not repair openclaw peer link for "${pluginId}" at ${installPath}: ${warnings.join("; ") || "peer link repair was skipped"}`, ); continue; } repaired = !installedPackageNeedsOpenClawPeerLinkRepair(installPath) || repaired; } catch (err) { params.logger.warn?.( `Could not repair openclaw peer link for "${pluginId}" at ${installPath}: ${String(err)}`, ); } } return repaired; } export async function updateNpmInstalledPlugins(params: { config: OpenClawConfig; logger?: PluginUpdateLogger; pluginIds?: string[]; skipIds?: Set; skipDisabledPlugins?: boolean; syncOfficialPluginInstalls?: boolean; disableOnFailure?: boolean; timeoutMs?: number; dryRun?: boolean; updateChannel?: UpdateChannel; dangerouslyForceUnsafeInstall?: boolean; specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; const installs = params.config.plugins?.installs ?? {}; const targets = params.pluginIds?.length ? params.pluginIds : Object.keys(installs); const normalizedPluginConfig = params.skipDisabledPlugins ? normalizePluginsConfig(params.config.plugins) : undefined; const bundled = resolveBundledPluginSources({}); const outcomes: PluginUpdateOutcome[] = []; let next = params.config; let changed = false; let ranNpmInstaller = false; const installNpmSpecForUpdate = async ( installParams: Parameters[0], ): Promise>> => { ranNpmInstaller = true; return await installPluginFromNpmSpec(installParams); }; const recordFailure = ( pluginId: string, message: string, channelFallback?: PluginUpdateChannelFallback, ) => { if (params.disableOnFailure && !params.dryRun) { const disabledMessage = `Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` + message; logger.warn?.(disabledMessage); next = disablePluginConfigEntry(next, pluginId); changed = true; outcomes.push({ pluginId, status: "skipped", message: disabledMessage, ...(channelFallback ? { channelFallback } : {}), }); return; } outcomes.push({ pluginId, status: "error", message, ...(channelFallback ? { channelFallback } : {}), }); }; for (const pluginId of targets) { if (params.skipIds?.has(pluginId)) { outcomes.push({ pluginId, status: "skipped", message: `Skipping "${pluginId}" (already updated).`, }); continue; } const record = installs[pluginId]; if (!record) { outcomes.push({ pluginId, status: "skipped", message: `No install record for "${pluginId}".`, }); continue; } const officialNpmSpec = params.syncOfficialPluginInstalls ? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record }) : undefined; const officialClawHubSpec = params.syncOfficialPluginInstalls ? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record }) : undefined; if (normalizedPluginConfig) { const enableState = resolveEffectiveEnableState({ id: pluginId, origin: "global", config: normalizedPluginConfig, rootConfig: params.config, }); if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) { outcomes.push({ pluginId, status: "skipped", message: `Skipping "${pluginId}" (${enableState.reason ?? "disabled by plugin config"}).`, }); continue; } } if (!isPluginInstallRecordUpdateSource(record)) { outcomes.push({ pluginId, status: "skipped", message: `Skipping "${pluginId}" (source: ${record.source}).`, }); continue; } const npmSpecs = record.source === "npm" ? resolveNpmUpdateSpecs({ record, specOverride: params.specOverrides?.[pluginId], officialSpecOverride: officialNpmSpec, updateChannel: params.updateChannel, }) : undefined; const clawhubSpecs = record.source === "clawhub" ? resolveClawHubUpdateSpecs({ record, officialSpecOverride: officialClawHubSpec, updateChannel: params.updateChannel, }) : undefined; const effectiveSpec = record.source === "npm" ? npmSpecs?.installSpec : record.source === "clawhub" ? clawhubSpecs?.installSpec : record.spec; const recordSpec = record.source === "npm" ? npmSpecs?.recordSpec : record.source === "clawhub" ? clawhubSpecs?.recordSpec : record.spec; const officialNpmFallbackSpecs = record.source === "clawhub" ? resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate({ pluginId, record, effectiveClawHubSpec: effectiveSpec, recordClawHubSpec: recordSpec, updateChannel: params.updateChannel, }) : null; let officialNpmFallbackInstallSpec = officialNpmFallbackSpecs?.installSpec; let officialNpmFallbackRecordSpec = officialNpmFallbackSpecs?.recordSpec; let activeClawHubInstallSpec = effectiveSpec; const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialNpmUpdate({ pluginId, spec: effectiveSpec, record, }); let expectedIntegrity = expectedIntegrityForNpmUpdate({ effectiveSpec, record, trustedSourceLinkedOfficialInstall, }); let fallbackExpectedIntegrityLoaded = false; let fallbackExpectedIntegrity: string | undefined; const getFallbackExpectedIntegrity = async () => { if (!fallbackExpectedIntegrityLoaded) { fallbackExpectedIntegrity = await expectedIntegrityForNpmFallback({ fallbackSpec: npmSpecs?.fallbackSpec, record, timeoutMs: params.timeoutMs, trustedSourceLinkedOfficialInstall, }); fallbackExpectedIntegrityLoaded = true; } return fallbackExpectedIntegrity; }; if (record.source === "npm" && !effectiveSpec) { outcomes.push({ pluginId, status: "skipped", message: `Skipping "${pluginId}" (missing npm spec).`, }); continue; } if (record.source === "git" && !effectiveSpec) { outcomes.push({ pluginId, status: "skipped", message: `Skipping "${pluginId}" (missing git spec).`, }); continue; } if (record.source === "clawhub" && !record.clawhubPackage && !officialClawHubSpec) { outcomes.push({ pluginId, status: "skipped", message: `Skipping "${pluginId}" (missing ClawHub package metadata).`, }); continue; } if (record.source === "clawhub" || record.source === "marketplace") { const bundledSource = bundled.get(pluginId); if ( bundledSource?.version && record.version && isBundledVersionNewer(bundledSource.version, record.version) ) { logger.warn?.( `Skipping "${pluginId}" update: bundled version ${bundledSource.version} is newer than the installed ${record.source} version ${record.version}. ` + `Uninstall the ${record.source} plugin to use the bundled version, or pin a newer version explicitly.`, ); outcomes.push({ pluginId, status: "skipped", message: `Skipping "${pluginId}": bundled version ${bundledSource.version} is newer than ${record.source} version ${record.version}.`, }); continue; } } if ( record.source === "marketplace" && (!record.marketplaceSource || !record.marketplacePlugin) ) { outcomes.push({ pluginId, status: "skipped", message: `Skipping "${pluginId}" (missing marketplace source metadata).`, }); continue; } let installPath: string; try { installPath = resolveUserPath( record.installPath?.trim() || resolvePluginInstallDir(pluginId), ); } catch (err) { recordFailure(pluginId, `Invalid install path for "${pluginId}": ${String(err)}`); continue; } let currentVersion: string | undefined; try { currentVersion = await readInstalledPackageVersion(installPath); } catch (err) { recordFailure( pluginId, `Failed to inspect installed package for ${pluginId}: ${String(err)}`, ); continue; } const extensionsDir = resolveRecordedExtensionsDir({ pluginId, installPath, }); if ( !params.dryRun && record.source === "npm" && (currentVersion || (params.syncOfficialPluginInstalls && trustedSourceLinkedOfficialInstall)) ) { const metadataResult = await resolveNpmSpecMetadata({ spec: effectiveSpec!, timeoutMs: params.timeoutMs, }); if (metadataResult.ok) { const bypassTrustedOfficialUnchangedNpmCheck = shouldBypassTrustedOfficialUnchangedNpmCheck( { metadata: metadataResult.metadata, spec: effectiveSpec!, trustedSourceLinkedOfficialInstall, }, ); const trustedPrereleaseFallback = trustedSourceLinkedOfficialInstall ? await resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate({ metadata: metadataResult.metadata, spec: effectiveSpec!, timeoutMs: params.timeoutMs, }) : undefined; const expectedIntegrityMetadata = trustedPrereleaseFallback?.metadata ?? metadataResult.metadata; expectedIntegrity = expectedIntegrityForNpmUpdate({ effectiveSpec, metadata: expectedIntegrityMetadata, record, trustedSourceLinkedOfficialInstall, }); if (!isNpmMetadataCompatibleWithCurrentHost(expectedIntegrityMetadata)) { expectedIntegrity = undefined; } if (bypassTrustedOfficialUnchangedNpmCheck && !trustedPrereleaseFallback) { expectedIntegrity = undefined; } if ( currentVersion && !bypassTrustedOfficialUnchangedNpmCheck && isNpmMetadataCompatibleWithCurrentHost(metadataResult.metadata) && !installedPackageNeedsOpenClawPeerLinkRepair(installPath) && shouldSkipUnchangedNpmInstall({ currentVersion, record, metadata: metadataResult.metadata, }) ) { if (params.syncOfficialPluginInstalls && trustedSourceLinkedOfficialInstall) { const nextRecordSpec = resolveNpmInstallRecordSpec({ requestedSpec: recordSpec, resolution: metadataResult.metadata, pinResolvedRegistrySpec: true, }); if (nextRecordSpec !== record.spec) { const resolutionFields = buildNpmResolutionInstallFields(metadataResult.metadata); next = { ...next, plugins: { ...next.plugins, installs: { ...next.plugins?.installs, [pluginId]: { ...record, spec: nextRecordSpec, resolvedName: resolutionFields.resolvedName ?? record.resolvedName, resolvedVersion: resolutionFields.resolvedVersion ?? record.resolvedVersion, resolvedSpec: resolutionFields.resolvedSpec ?? record.resolvedSpec, integrity: resolutionFields.integrity ?? record.integrity, shasum: resolutionFields.shasum ?? record.shasum, resolvedAt: resolutionFields.resolvedAt ?? record.resolvedAt, }, }, }, }; changed = true; } } outcomes.push({ pluginId, status: "unchanged", currentVersion, nextVersion: metadataResult.metadata.version, message: `${pluginId} is up to date (${currentVersion}).`, }); continue; } } else { logger.warn?.( `Could not check ${pluginId} before update; falling back to installer path: ${metadataResult.error}`, ); } } if (params.dryRun) { let probe: | Awaited> | Awaited> | Awaited> | Awaited>; try { probe = record.source === "npm" ? await installPluginFromNpmSpec({ spec: effectiveSpec!, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall, expectedPluginId: pluginId, expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: true, logger, onIntegrityDrift: params.onIntegrityDrift, }), logger, }) : record.source === "clawhub" ? await installPluginFromClawHub({ spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, config: params.config, baseUrl: record.clawhubUrl, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, }) : record.source === "git" ? await installPluginFromGitSpec({ spec: effectiveSpec!, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, }) : await installPluginFromMarketplace({ marketplace: record.marketplaceSource!, plugin: record.marketplacePlugin!, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, }); } catch (err) { recordFailure(pluginId, `Failed to check ${pluginId}: ${String(err)}`); continue; } let usedNpmFallback = false; let usedOfficialNpmFallback = false; let channelFallbackSuffix = ""; let npmChannelFallback: PluginUpdateChannelFallback | undefined; if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( describeBetaNpmFallback({ pluginId, betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec, fallbackSpec: npmSpecs.fallbackSpec, result: probe, }), ); usedNpmFallback = true; npmChannelFallback = describeNpmChannelFallback({ pluginId, requestedSpec: npmSpecs.fallbackLabel ?? effectiveSpec, usedSpec: npmSpecs.fallbackSpec, result: probe, verb: "would use", }); channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({ fallbackLabel: npmSpecs.fallbackLabel ?? effectiveSpec, fallbackSpec: npmSpecs.fallbackSpec, verb: "would use", }); probe = await installPluginFromNpmSpec({ spec: npmSpecs.fallbackSpec, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall, expectedPluginId: pluginId, expectedIntegrity: await getFallbackExpectedIntegrity(), onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: true, logger, onIntegrityDrift: params.onIntegrityDrift, }), logger, }); } if ( !probe.ok && record.source === "clawhub" && clawhubSpecs?.fallbackSpec && shouldFallbackBetaClawHubUpdate(probe) ) { channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({ fallbackLabel: clawhubSpecs.fallbackLabel ?? effectiveSpec, fallbackSpec: clawhubSpecs.fallbackSpec, verb: "would use", }); logger.warn?.( `Plugin "${pluginId}" has no beta ClawHub release for ${clawhubSpecs.fallbackLabel ?? effectiveSpec}; using ${clawhubSpecs.fallbackSpec} instead. Core update can still complete.`, ); probe = await installPluginFromClawHub({ spec: clawhubSpecs.fallbackSpec, config: params.config, baseUrl: record.clawhubUrl, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, }); activeClawHubInstallSpec = clawhubSpecs.fallbackSpec; if (officialNpmFallbackSpecs?.fallbackSpec) { officialNpmFallbackInstallSpec = officialNpmFallbackSpecs.fallbackSpec; } } if ( !probe.ok && record.source === "clawhub" && officialNpmFallbackInstallSpec && shouldFallbackClawHubBridgeToNpm({ result: probe, npmSpec: officialNpmFallbackInstallSpec, }) ) { channelFallbackSuffix = ` (warning: official ClawHub artifact fallback would use ${officialNpmFallbackInstallSpec}).`; logger.warn?.( `Plugin "${pluginId}" could not download official ClawHub artifact for ${activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`}; using npm ${officialNpmFallbackInstallSpec} instead. Core update can still complete.`, ); usedNpmFallback = true; usedOfficialNpmFallback = true; probe = await installPluginFromNpmSpec({ spec: officialNpmFallbackInstallSpec, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: true, expectedPluginId: pluginId, logger, }); } if (!probe.ok) { recordFailure( pluginId, record.source === "npm" || usedOfficialNpmFallback ? formatNpmInstallFailure({ pluginId, spec: usedOfficialNpmFallback ? (officialNpmFallbackInstallSpec ?? effectiveSpec ?? "") : npmUpdateFailureSpec({ effectiveSpec, fallbackSpec: npmSpecs?.fallbackSpec, usedFallback: usedNpmFallback, }), phase: "check", result: probe, }) : record.source === "clawhub" ? formatClawHubInstallFailure({ pluginId, spec: activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`, phase: "check", error: probe.error, }) : record.source === "git" ? formatGitInstallFailure({ pluginId, spec: effectiveSpec!, phase: "check", error: probe.error, }) : formatMarketplaceInstallFailure({ pluginId, marketplaceSource: record.marketplaceSource!, marketplacePlugin: record.marketplacePlugin!, phase: "check", error: probe.error, }), npmChannelFallback, ); continue; } const probeSpec = usedNpmFallback ? (npmSpecs?.fallbackSpec ?? officialNpmFallbackInstallSpec) : effectiveSpec; const npmProbeVersion = record.source === "npm" || usedOfficialNpmFallback ? resolveNpmResultVersion(probe) : undefined; const resolvedProbeVersion = probe.version ?? npmProbeVersion ?? (record.source === "npm" || usedOfficialNpmFallback ? resolveExactNpmSpecVersion(probeSpec) : undefined); const nextVersion = resolvedProbeVersion ?? "unknown"; const currentLabel = currentVersion ?? "unknown"; const gitProbe = record.source === "git" ? (probe as Extract>, { ok: true }>) .git : undefined; const unchanged = record.source === "git" && record.gitCommit && gitProbe?.commit ? record.gitCommit === gitProbe.commit : Boolean( currentVersion && resolvedProbeVersion && currentVersion === resolvedProbeVersion, ); if (unchanged) { outcomes.push({ pluginId, status: "unchanged", currentVersion: currentVersion ?? undefined, nextVersion: resolvedProbeVersion, message: `${pluginId} is up to date (${currentLabel}).${channelFallbackSuffix}`, ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), }); } else { outcomes.push({ pluginId, status: "updated", currentVersion: currentVersion ?? undefined, nextVersion: resolvedProbeVersion, message: `Would update ${pluginId}: ${currentLabel} -> ${nextVersion}.${channelFallbackSuffix}`, ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), }); } continue; } let result: | Awaited> | Awaited> | Awaited> | Awaited>; try { result = record.source === "npm" ? await installNpmSpecForUpdate({ spec: effectiveSpec!, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall, expectedPluginId: pluginId, expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: false, logger, onIntegrityDrift: params.onIntegrityDrift, }), logger, }) : record.source === "clawhub" ? await installPluginFromClawHub({ spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`, config: params.config, baseUrl: record.clawhubUrl, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, }) : record.source === "git" ? await installPluginFromGitSpec({ spec: effectiveSpec!, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, }) : await installPluginFromMarketplace({ marketplace: record.marketplaceSource!, plugin: record.marketplacePlugin!, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, }); } catch (err) { recordFailure(pluginId, `Failed to update ${pluginId}: ${String(err)}`); continue; } let usedNpmFallback = false; let usedOfficialNpmFallback = false; let channelFallbackSuffix = ""; let resultSource = record.source; activeClawHubInstallSpec = effectiveSpec; let npmChannelFallback: PluginUpdateChannelFallback | undefined; if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) { logger.warn?.( describeBetaNpmFallback({ pluginId, betaSpec: npmSpecs.fallbackLabel ?? effectiveSpec, fallbackSpec: npmSpecs.fallbackSpec, result, }), ); usedNpmFallback = true; npmChannelFallback = describeNpmChannelFallback({ pluginId, requestedSpec: npmSpecs.fallbackLabel ?? effectiveSpec, usedSpec: npmSpecs.fallbackSpec, result, verb: "used", }); channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({ fallbackLabel: npmSpecs.fallbackLabel ?? effectiveSpec, fallbackSpec: npmSpecs.fallbackSpec, verb: "used", }); result = await installNpmSpecForUpdate({ spec: npmSpecs.fallbackSpec, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall, expectedPluginId: pluginId, expectedIntegrity: await getFallbackExpectedIntegrity(), onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: false, logger, onIntegrityDrift: params.onIntegrityDrift, }), logger, }); } if ( !result.ok && record.source === "clawhub" && clawhubSpecs?.fallbackSpec && shouldFallbackBetaClawHubUpdate(result) ) { channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({ fallbackLabel: clawhubSpecs.fallbackLabel ?? effectiveSpec, fallbackSpec: clawhubSpecs.fallbackSpec, verb: "used", }); logger.warn?.( `Plugin "${pluginId}" has no beta ClawHub release for ${clawhubSpecs.fallbackLabel ?? effectiveSpec}; using ${clawhubSpecs.fallbackSpec} instead. Core update can still complete.`, ); result = await installPluginFromClawHub({ spec: clawhubSpecs.fallbackSpec, config: params.config, baseUrl: record.clawhubUrl, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, logger, }); activeClawHubInstallSpec = clawhubSpecs.fallbackSpec; if (officialNpmFallbackSpecs?.fallbackSpec) { officialNpmFallbackInstallSpec = officialNpmFallbackSpecs.fallbackSpec; officialNpmFallbackRecordSpec = officialNpmFallbackSpecs.fallbackSpec; } } if ( !result.ok && record.source === "clawhub" && officialNpmFallbackInstallSpec && shouldFallbackClawHubBridgeToNpm({ result, npmSpec: officialNpmFallbackInstallSpec, }) ) { logger.warn?.( `Plugin "${pluginId}" could not download official ClawHub artifact for ${activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`}; using npm ${officialNpmFallbackInstallSpec} instead. Core update can still complete.`, ); usedNpmFallback = true; usedOfficialNpmFallback = true; resultSource = "npm"; channelFallbackSuffix = ` (warning: official ClawHub artifact fallback used ${officialNpmFallbackInstallSpec}).`; result = await installNpmSpecForUpdate({ spec: officialNpmFallbackInstallSpec, config: params.config, mode: "update", extensionsDir, timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, trustedSourceLinkedOfficialInstall: true, expectedPluginId: pluginId, logger, }); } if (!result.ok) { recordFailure( pluginId, resultSource === "npm" ? formatNpmInstallFailure({ pluginId, spec: usedOfficialNpmFallback ? (officialNpmFallbackInstallSpec ?? effectiveSpec ?? "") : npmUpdateFailureSpec({ effectiveSpec, fallbackSpec: npmSpecs?.fallbackSpec, usedFallback: usedNpmFallback, }), phase: "update", result, }) : resultSource === "clawhub" ? formatClawHubInstallFailure({ pluginId, spec: activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`, phase: "update", error: result.error, }) : record.source === "git" ? formatGitInstallFailure({ pluginId, spec: effectiveSpec!, phase: "update", error: result.error, }) : formatMarketplaceInstallFailure({ pluginId, marketplaceSource: record.marketplaceSource!, marketplacePlugin: record.marketplacePlugin!, phase: "update", error: result.error, }), npmChannelFallback, ); continue; } const resolvedPluginId = result.pluginId; if (resolvedPluginId !== pluginId) { next = migratePluginConfigId(next, pluginId, resolvedPluginId); } const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); if (resultSource === "npm") { const npmResult = result as Extract< Awaited>, { ok: true } >; next = recordPluginInstall( usedOfficialNpmFallback ? withoutPluginInstallRecord(next, resolvedPluginId) : next, { pluginId: resolvedPluginId, source: "npm", spec: resolveNpmInstallRecordSpec({ requestedSpec: usedOfficialNpmFallback ? officialNpmFallbackRecordSpec : recordSpec, resolution: npmResult.npmResolution, pinResolvedRegistrySpec: (params.syncOfficialPluginInstalls && trustedSourceLinkedOfficialInstall) || usedOfficialNpmFallback, }), installPath: result.targetDir, version: nextVersion, ...buildNpmResolutionInstallFields(npmResult.npmResolution), }, ); } else if (resultSource === "clawhub") { const clawhubResult = result as Extract< Awaited>, { ok: true } >; next = recordPluginInstall(next, { pluginId: resolvedPluginId, ...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub), spec: recordSpec ?? record.spec ?? `clawhub:${record.clawhubPackage!}`, installPath: result.targetDir, version: nextVersion, }); } else if (record.source === "git") { const gitResult = result as Extract< Awaited>, { ok: true } >; next = recordPluginInstall(next, { pluginId: resolvedPluginId, source: "git", spec: effectiveSpec ?? record.spec, installPath: result.targetDir, version: nextVersion, resolvedAt: gitResult.git.resolvedAt, gitUrl: gitResult.git.url, gitRef: gitResult.git.ref, gitCommit: gitResult.git.commit, }); } else { const marketplaceResult = result as Extract< Awaited>, { ok: true } >; next = recordPluginInstall(next, { pluginId: resolvedPluginId, source: "marketplace", installPath: result.targetDir, version: nextVersion, marketplaceName: marketplaceResult.marketplaceName ?? record.marketplaceName, marketplaceSource: record.marketplaceSource, marketplacePlugin: record.marketplacePlugin, }); } changed = true; const currentLabel = currentVersion ?? "unknown"; const nextLabel = nextVersion ?? "unknown"; if (currentVersion && nextVersion && currentVersion === nextVersion) { outcomes.push({ pluginId, status: "unchanged", currentVersion: currentVersion ?? undefined, nextVersion: nextVersion ?? undefined, message: `${pluginId} already at ${currentLabel}.${channelFallbackSuffix}`, ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), }); } else { outcomes.push({ pluginId, status: "updated", currentVersion: currentVersion ?? undefined, nextVersion: nextVersion ?? undefined, message: `Updated ${pluginId}: ${currentLabel} -> ${nextLabel}.${channelFallbackSuffix}`, ...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}), }); } } if (ranNpmInstaller) { changed = (await repairOpenClawPeerLinksForNpmInstalls({ config: next, logger, })) || changed; } return { config: next, changed, outcomes }; } export async function syncPluginsForUpdateChannel(params: { config: OpenClawConfig; channel: UpdateChannel; workspaceDir?: string; env?: NodeJS.ProcessEnv; logger?: PluginUpdateLogger; externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[]; }): Promise { const env = params.env ?? process.env; const logger = params.logger ?? {}; const summary: PluginChannelSyncSummary = { switchedToBundled: [], switchedToClawHub: [], switchedToNpm: [], warnings: [], errors: [], }; const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir, env, }); let next = params.config; const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env); let installs = next.plugins?.installs ?? {}; let changed = false; if (params.channel === "dev") { for (const [pluginId, record] of Object.entries(installs)) { const bundledInfo = bundled.get(pluginId); if (!bundledInfo) { continue; } loadHelpers.addPath(bundledInfo.localPath); const alreadyBundled = record.source === "path" && pathsEqual(record.sourcePath, bundledInfo.localPath, env); if (alreadyBundled) { continue; } next = recordPluginInstall(next, { pluginId, source: "path", sourcePath: bundledInfo.localPath, installPath: bundledInfo.localPath, spec: record.spec ?? bundledInfo.npmSpec, version: record.version, }); summary.switchedToBundled.push(pluginId); changed = true; } } else { const bridges = params.externalizedBundledPluginBridges ?? []; for (const bridge of bridges) { const targetPluginId = getExternalizedBundledPluginTargetId(bridge); const bundledInfo = bundled.get(bridge.bundledPluginId); if (bundledInfo) { continue; } const existing = resolveBridgeInstallRecord({ installs, bridge }); if ( !existing && !isExternalizedBundledPluginEnabled({ config: next, bridge, }) ) { continue; } if ( existing && !isExternalizedBundledPluginEnabled({ config: next, bridge, }) ) { continue; } if ( existing && isBridgeAlreadyInstalledFromPreferredSource({ bridge, record: existing.record, }) ) { if (existing.pluginId !== targetPluginId) { next = migratePluginConfigId(next, existing.pluginId, targetPluginId); installs = next.plugins?.installs ?? {}; changed = true; } removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env }); continue; } if ( existing && !isBridgeBundledPathRecord({ bridge, record: existing.record, env, }) && !isBridgeInstalledFromFallbackSource({ bridge, record: existing.record, }) ) { continue; } const preferredSource = getExternalizedBundledPluginPreferredSource(bridge); const npmSpec = getExternalizedBundledPluginNpmSpec(bridge); const clawhubSpec = getExternalizedBundledPluginClawHubSpec(bridge); const trustedSourceLinkedOfficialInstall = isTrustedSourceLinkedOfficialBridgeNpmInstall({ targetPluginId, npmSpec, }); let installSource = preferredSource; let installSpec = preferredSource === "clawhub" ? clawhubSpec : npmSpec; let result: | Awaited> | Awaited>; if (!installSpec) { const message = `Failed to update ${targetPluginId}: missing ${preferredSource} install spec for externalized bundled plugin.`; summary.errors.push(message); logger.error?.(message); continue; } if (preferredSource === "clawhub") { result = await installPluginFromClawHub({ spec: clawhubSpec, config: params.config, ...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}), mode: "update", expectedPluginId: targetPluginId, logger, }); if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm({ result, npmSpec })) { const warning = `ClawHub ${clawhubSpec} unavailable for ${targetPluginId}; falling back to npm ${npmSpec}.`; summary.warnings.push(warning); logger.warn?.(warning); installSource = "npm"; installSpec = npmSpec; result = await installPluginFromNpmSpec({ spec: npmSpec, config: params.config, mode: "update", expectedPluginId: targetPluginId, trustedSourceLinkedOfficialInstall, logger, }); } } else { result = await installPluginFromNpmSpec({ spec: npmSpec, config: params.config, mode: "update", expectedPluginId: targetPluginId, trustedSourceLinkedOfficialInstall, logger, }); } if (!result.ok) { const message = installSource === "clawhub" ? formatClawHubInstallFailure({ pluginId: targetPluginId, spec: installSpec, phase: "update", error: result.error, }) : formatNpmInstallFailure({ pluginId: targetPluginId, spec: installSpec, phase: "update", result, }); summary.errors.push(message); logger.error?.(message); continue; } const resolvedPluginId = result.pluginId; if (existing && existing.pluginId !== resolvedPluginId) { next = migratePluginConfigId(next, existing.pluginId, resolvedPluginId); } const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); if (installSource === "clawhub") { const clawhubResult = result as Extract< Awaited>, { ok: true } >; next = recordPluginInstall(next, { pluginId: resolvedPluginId, ...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub), spec: installSpec, installPath: result.targetDir, version: nextVersion, }); } else { const npmResult = result as Extract< Awaited>, { ok: true } >; next = recordPluginInstall(next, { pluginId: resolvedPluginId, source: "npm", spec: resolveNpmInstallRecordSpec({ requestedSpec: installSpec, resolution: npmResult.npmResolution, pinResolvedRegistrySpec: trustedSourceLinkedOfficialInstall, }), installPath: result.targetDir, version: nextVersion, ...buildNpmResolutionInstallFields(npmResult.npmResolution), }); } installs = next.plugins?.installs ?? {}; if (existing?.record.sourcePath) { loadHelpers.removePath(existing.record.sourcePath); } if (existing?.record.installPath) { loadHelpers.removePath(existing.record.installPath); } removeBridgeBundledLoadPaths({ bridge, loadPaths: loadHelpers, env }); if (installSource === "clawhub") { summary.switchedToClawHub.push(resolvedPluginId); } else { summary.switchedToNpm.push(resolvedPluginId); } changed = true; } for (const [pluginId, record] of Object.entries(installs)) { const bundledInfo = bundled.get(pluginId); if (!bundledInfo) { continue; } if (record.source === "npm") { loadHelpers.removePath(bundledInfo.localPath); continue; } if (record.source !== "path") { continue; } if (!pathsEqual(record.sourcePath, bundledInfo.localPath, env)) { continue; } // Keep explicit bundled installs on release channels. Replacing them with // npm installs can reintroduce duplicate-id shadowing and packaging drift. loadHelpers.addPath(bundledInfo.localPath); const alreadyBundled = record.source === "path" && pathsEqual(record.sourcePath, bundledInfo.localPath, env) && pathsEqual(record.installPath, bundledInfo.localPath, env); if (alreadyBundled) { continue; } next = recordPluginInstall(next, { pluginId, source: "path", sourcePath: bundledInfo.localPath, installPath: bundledInfo.localPath, spec: record.spec ?? bundledInfo.npmSpec, version: record.version, }); changed = true; } } if (loadHelpers.changed) { next = { ...next, plugins: { ...next.plugins, load: { ...next.plugins?.load, paths: loadHelpers.paths, }, }, }; changed = true; } return { config: next, changed, summary }; }