Files
openclaw/src/plugins/update-installed.ts
Peter Steinberger a2eb27c03a refactor(plugins): split update lifecycle (#108877)
* refactor(plugins): split update lifecycle

* fix(plugins): break update type cycle

* fix(plugins): keep update modules below line budget

* fix(plugins): preserve update export surface
2026-07-16 07:54:21 -07:00

707 lines
25 KiB
TypeScript

import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveNpmSpecMetadata } from "../infra/install-source-utils.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import {
installedPackageNeedsOpenClawPeerLinkRepair,
readInstalledPackageManifest,
readInstalledPackageVersion,
} from "../infra/package-update-utils.js";
import type { UpdateChannel } from "../infra/update-channels.js";
import { resolveUserPath } from "../utils.js";
import { resolveBundledPluginSources } from "./bundled-sources.js";
import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
import type { ClawHubRiskAcknowledgementRequest } from "./clawhub.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
import { resolvePluginInstallDir } from "./install.js";
import {
buildNpmResolutionInstallFields,
recordPluginInstall,
resolveNpmInstallRecordSpec,
} from "./installs.js";
import type { PackageManifest } from "./manifest.js";
import {
resolveTrustedSourceLinkedOfficialClawHubSpec,
resolveTrustedSourceLinkedOfficialNpmSpec,
} from "./official-external-install-records.js";
import {
buildClawHubTrustSkippedOutcome,
buildDryRunPluginUpdateOutcome,
formatClawHubInstallFailure,
formatGitInstallFailure,
formatMarketplaceInstallFailure,
formatNpmInstallFailure,
readClawHubTrustErrorCode,
runPluginUpdateAttempt,
shouldSkipClawHubTrustFailureForExistingInstall,
type ClawHubPluginUpdateSuccess,
type GitPluginUpdateSuccess,
type MarketplacePluginUpdateSuccess,
type NpmPluginUpdateSuccess,
} from "./update-attempt.js";
import {
disablePluginAfterUpdateFailure,
hasRunnableInstalledNpmPayload,
migratePluginConfigId,
repairOpenClawPeerLinksForNpmInstalls,
resolveRecordedExtensionsDir,
withoutPluginInstallRecord,
} from "./update-config.js";
import {
expectedIntegrityForNpmFallback,
expectedIntegrityForNpmUpdate,
isBundledVersionNewer,
isNpmMetadataCompatibleWithCurrentHost,
isPluginInstallRecordUpdateSource,
isTrustedSourceLinkedOfficialNpmUpdate,
npmUpdateFailureSpec,
resolveClawHubUpdateSpecs,
resolveNpmSpecPackageName,
resolveNpmUpdateSpecs,
resolveTrustedOfficialPrereleaseFallbackMetadataForUpdate,
resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate,
shouldBypassTrustedOfficialUnchangedNpmCheck,
shouldSkipUnchangedNpmInstall,
type PluginUpdateChannelFallback,
type PluginUpdateIntegrityDriftParams,
type PluginUpdateLogger,
type PluginUpdateOutcome,
type PluginUpdateSummary,
} from "./update-source.js";
export async function updateNpmInstalledPlugins(params: {
config: OpenClawConfig;
logger?: PluginUpdateLogger;
pluginIds?: string[];
skipIds?: Set<string>;
skipDisabledPlugins?: boolean;
syncOfficialPluginInstalls?: boolean;
disableOnFailure?: boolean;
timeoutMs?: number;
dryRun?: boolean;
updateChannel?: UpdateChannel;
officialPluginUpdateChannel?: UpdateChannel;
coreVersion?: string;
dangerouslyForceUnsafeInstall?: boolean;
specOverrides?: Record<string, string>;
onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
}): Promise<PluginUpdateSummary> {
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<typeof installPluginFromNpmSpec>[0],
): Promise<Awaited<ReturnType<typeof installPluginFromNpmSpec>>> => {
ranNpmInstaller = true;
return await installPluginFromNpmSpec(installParams);
};
const clawHubRiskAcknowledgementOptions = {
...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
...(!params.dryRun && params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}),
};
const recordFailure = (
pluginId: string,
message: string,
options: {
channelFallback?: PluginUpdateChannelFallback;
code?: string;
installedPayloadRunnable?: boolean;
} = {},
) => {
// Metadata failure is advisory only when a runnable payload is still installed.
// Missing-payload repair must keep disabling the broken config entry.
const preserveInstalledPayload =
options.code === PLUGIN_INSTALL_ERROR_CODE.NPM_METADATA_FAILURE &&
options.installedPayloadRunnable === true;
if (params.disableOnFailure && !params.dryRun && !preserveInstalledPayload) {
const disabledMessage =
`Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` +
message;
logger.warn?.(disabledMessage);
next = disablePluginAfterUpdateFailure(next, pluginId);
changed = true;
outcomes.push({
pluginId,
status: "skipped",
message: disabledMessage,
...(options.channelFallback ? { channelFallback: options.channelFallback } : {}),
});
return;
}
outcomes.push({
pluginId,
status: "error",
message,
...(options.channelFallback ? { channelFallback: options.channelFallback } : {}),
});
};
for (const pluginId of targets) {
if (params.skipIds?.has(pluginId)) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (already updated).`,
});
continue;
}
const record = Object.hasOwn(installs, pluginId) ? installs[pluginId] : undefined;
if (!record) {
outcomes.push({
pluginId,
status: "skipped",
message: `No install record for "${pluginId}".`,
});
continue;
}
const trustedOfficialNpmSpec = resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record });
const officialNpmSpec = params.syncOfficialPluginInstalls ? trustedOfficialNpmSpec : undefined;
const officialClawHubSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
: undefined;
const officialSyncUpdateChannel = params.officialPluginUpdateChannel ?? params.updateChannel;
const officialNpmPackageName = resolveNpmSpecPackageName(trustedOfficialNpmSpec);
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: officialNpmSpec ? officialSyncUpdateChannel : params.updateChannel,
officialPackageName: officialNpmPackageName,
coreVersion: params.coreVersion,
})
: undefined;
const clawhubSpecs =
record.source === "clawhub"
? resolveClawHubUpdateSpecs({
record,
officialSpecOverride: officialClawHubSpec,
updateChannel: officialClawHubSpec ? officialSyncUpdateChannel : 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 preserveNpmRecordIntent =
record.source === "npm" &&
npmSpecs?.installSpec !== npmSpecs?.recordSpec &&
(officialNpmSpec ? officialSyncUpdateChannel : params.updateChannel) === "extended-stable";
const officialNpmFallbackSpecs =
record.source === "clawhub"
? resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate({
pluginId,
record,
effectiveClawHubSpec: effectiveSpec,
recordClawHubSpec: recordSpec,
updateChannel: params.syncOfficialPluginInstalls
? officialSyncUpdateChannel
: params.updateChannel,
coreVersion: params.coreVersion,
})
: null;
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;
let installedManifest: PackageManifest | undefined;
try {
installedManifest = readInstalledPackageManifest(installPath) as PackageManifest | undefined;
currentVersion =
typeof installedManifest?.version === "string" ? installedManifest.version : undefined;
} catch (err) {
recordFailure(
pluginId,
`Failed to inspect installed package for ${pluginId}: ${String(err)}`,
);
continue;
}
// Payload validation is filesystem work needed only to preserve state after metadata failures.
// Every failure path below ends this plugin iteration, so the result cannot be reused.
const hasRunnableInstalledPayloadForFailure = async (code?: string): Promise<boolean> => {
if (
code !== PLUGIN_INSTALL_ERROR_CODE.NPM_METADATA_FAILURE ||
!params.disableOnFailure ||
params.dryRun ||
currentVersion === undefined
) {
return false;
}
try {
return await hasRunnableInstalledNpmPayload({ installPath, manifest: installedManifest });
} catch {
// Damaged or unreadable payloads fail closed without aborting the remaining plugin sweep.
return false;
}
};
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: !preserveNpmRecordIntent,
});
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 {
if (!parseRegistryNpmSpec(effectiveSpec!)) {
const code =
metadataResult.category === "metadata-env"
? PLUGIN_INSTALL_ERROR_CODE.NPM_METADATA_FAILURE
: undefined;
recordFailure(pluginId, `Failed to check ${pluginId}: ${metadataResult.error}`, {
code,
installedPayloadRunnable: await hasRunnableInstalledPayloadForFailure(code),
});
continue;
}
logger.warn?.(
`Could not check ${pluginId} before update; falling back to installer path: ${metadataResult.error}`,
);
}
}
const attempt = await runPluginUpdateAttempt({
pluginId,
record,
config: params.config,
dryRun: params.dryRun === true,
effectiveSpec,
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedIntegrity,
npmSpecs,
clawhubSpecs,
officialNpmFallbackSpecs,
trustedSourceLinkedOfficialInstall,
getFallbackExpectedIntegrity,
installNpmSpecForUpdate,
logger,
onIntegrityDrift: params.onIntegrityDrift,
clawHubRiskAcknowledgementOptions,
});
if (attempt.kind === "exception") {
recordFailure(pluginId, attempt.message);
continue;
}
const {
result,
activeClawHubInstallSpec,
channelFallbackSuffix,
npmChannelFallback,
officialNpmFallbackInstallSpec,
officialNpmFallbackRecordSpec,
resultSource,
usedNpmFallback,
usedOfficialNpmFallback,
} = attempt;
if (!result.ok) {
if (
record.source === "clawhub" &&
shouldSkipClawHubTrustFailureForExistingInstall({ result, currentVersion })
) {
const code = readClawHubTrustErrorCode(result);
if (!code) {
continue;
}
outcomes.push(
buildClawHubTrustSkippedOutcome({
pluginId,
phase: params.dryRun ? "check" : "update",
error: result.error,
code,
...("warning" in result && result.warning ? { warning: result.warning } : {}),
...(currentVersion ? { currentVersion } : {}),
}),
);
continue;
}
const phase = params.dryRun ? "check" : "update";
const code = resultSource === "npm" && "code" in result ? result.code : undefined;
const message =
resultSource === "npm"
? formatNpmInstallFailure({
pluginId,
spec: usedOfficialNpmFallback
? (officialNpmFallbackInstallSpec ?? effectiveSpec ?? "")
: npmUpdateFailureSpec({
effectiveSpec,
fallbackSpec: npmSpecs?.fallbackSpec,
usedFallback: usedNpmFallback,
}),
phase,
result,
})
: resultSource === "clawhub"
? formatClawHubInstallFailure({
pluginId,
spec: activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`,
phase,
error: result.error,
})
: record.source === "git"
? formatGitInstallFailure({
pluginId,
spec: effectiveSpec!,
phase,
error: result.error,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase,
error: result.error,
});
recordFailure(pluginId, message, {
channelFallback: npmChannelFallback,
code,
installedPayloadRunnable: await hasRunnableInstalledPayloadForFailure(code),
});
continue;
}
if (params.dryRun) {
outcomes.push(
await buildDryRunPluginUpdateOutcome({
pluginId,
record,
result,
currentVersion,
effectiveSpec,
fallbackSpec: npmSpecs?.fallbackSpec,
officialNpmFallbackInstallSpec,
usedNpmFallback,
usedOfficialNpmFallback,
hasSpecOverride: Boolean(params.specOverrides?.[pluginId]),
hasOfficialNpmSpec: Boolean(officialNpmSpec),
timeoutMs: params.timeoutMs,
channelFallbackSuffix,
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 NpmPluginUpdateSuccess;
next = recordPluginInstall(
usedOfficialNpmFallback ? withoutPluginInstallRecord(next, resolvedPluginId) : next,
{
pluginId: resolvedPluginId,
source: "npm",
spec: resolveNpmInstallRecordSpec({
requestedSpec: usedOfficialNpmFallback ? officialNpmFallbackRecordSpec : recordSpec,
resolution: npmResult.npmResolution,
pinResolvedRegistrySpec:
(params.syncOfficialPluginInstalls &&
trustedSourceLinkedOfficialInstall &&
!preserveNpmRecordIntent) ||
(usedOfficialNpmFallback && officialSyncUpdateChannel !== "extended-stable"),
}),
installPath: result.targetDir,
version: nextVersion,
...buildNpmResolutionInstallFields(npmResult.npmResolution),
},
);
} else if (resultSource === "clawhub") {
const clawhubResult = result as ClawHubPluginUpdateSuccess;
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 GitPluginUpdateSuccess;
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 MarketplacePluginUpdateSuccess;
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 };
}