Files
openclaw/src/cli/update-cli/update-command-plugins.ts
Peter Steinberger c6b2ec28c8 refactor: mechanical dedup batch (protocol types, update-cli bridge, talk fallback) (#114432)
* refactor(onboarding): remove search setup barrel

* refactor(plugins): reuse detected package manifest

* test(update): replace global helper bridges

* refactor(talk): remove dead legacy response fallback

* refactor(protocol): derive root types from schema

The maintainer approved broadening the additive schema-backed type surface without a protocol version bump.

* fix(plugins): drop stale package path import

* test(protocol): type dynamic registry lookups

* docs(talk): explain canonical response boundary

* refactor(talk): enforce canonical response input

* fix(protocol): keep root type exports registry-free

* refactor(update): expose helpers through test facades

* fix(protocol): keep result types on leaf schema modules
2026-07-27 06:55:07 -04:00

601 lines
21 KiB
TypeScript

// Plugin synchronization and convergence after the core update.
import { confirm, isCancel, text } from "@clack/prompts";
import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js";
import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js";
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js";
import type { UpdateChannel } from "../../infra/update-channels.js";
import { commitPluginInstallRecordsWithConfig } from "../../plugins/install-record-commit.js";
import {
loadInstalledPluginIndexInstallRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../../plugins/installed-plugin-index-records.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
import {
isClawHubTrustSkippedOutcome,
syncPluginsForUpdateChannel,
updateNpmInstalledPlugins,
type PluginUpdateIntegrityDriftParams,
type PluginUpdateOutcome,
} from "../../plugins/update.js";
import { defaultRuntime } from "../../runtime.js";
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
import {
convergenceWarningsToOutcomes,
runPostCorePluginConvergence,
} from "./post-core-plugin-convergence.js";
import { readPackageVersion, type UpdateCommandOptions } from "./shared.js";
import {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
type MissingPluginInstallPayload,
type PostCorePluginUpdateResult,
} from "./update-command-plugins-internals.js";
export type { PostCorePluginUpdateResult } from "./update-command-plugins-internals.js";
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE =
"Run openclaw update repair to retry post-update plugin repair.";
type PostUpdatePluginWarning = NonNullable<PostCorePluginUpdateResult["warnings"]>[number];
function isClawHubTrustNotice(message: string): boolean {
const trimmed = stripAnsi(message).trimStart();
return (
trimmed.startsWith("ClawHub trust warning ") ||
trimmed.startsWith("╭─ REVIEW RECOMMENDED - ClawHub ") ||
trimmed.startsWith("╭─ WARNING - ClawHub found security risks ") ||
trimmed.startsWith("╭─ BLOCKED - ClawHub ")
);
}
function isNonBlockingClawHubTrustNotice(message: string): boolean {
const trimmed = stripAnsi(message).trimStart();
return (
trimmed.startsWith("ClawHub trust warning ") ||
trimmed.startsWith("╭─ REVIEW RECOMMENDED - ClawHub ")
);
}
function formatPluginUpdateWarning(message: string): string {
return message.includes("╭─") ? message : theme.warn(message);
}
function resolveUpdateClawHubRiskAcknowledgementOptions(
opts: UpdateCommandOptions,
params: {
renderWarningBeforePrompt?: (warning: string) => void;
} = {},
): {
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise<boolean>;
} {
if (opts.acknowledgeClawHubRisk) {
return { acknowledgeClawHubRisk: true };
}
if (opts.dryRun || opts.yes || opts.json || !process.stdin.isTTY || !process.stdout.isTTY) {
return {};
}
return {
onClawHubRisk: async (request) => {
params.renderWarningBeforePrompt?.(request.warning);
const packageName = sanitizeTerminalText(request.packageName);
const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`;
if (request.acknowledgementKind === "type-package") {
const answer = await text({
message: stylePromptMessage(`type: '${packageName}' to update anyway`),
placeholder: packageName,
});
return !isCancel(answer) && answer.trim() === packageName;
}
const ok = await confirm({
message: stylePromptMessage(
`Update ClawHub package "${releaseLabel}" after reviewing the warning above?`,
),
initialValue: false,
});
return !isCancel(ok) && ok;
},
};
}
function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): string {
if (entry.reason === "missing-install-path") {
return "installPath is missing";
}
if (entry.reason === "missing-package-json") {
return `package.json is missing under ${entry.installPath}`;
}
return `package directory is missing: ${entry.installPath}`;
}
function formatPostUpdatePluginInspectGuidance(pluginId: string): string {
return `Run openclaw plugins inspect ${pluginId} --runtime --json for details.`;
}
function createPostUpdatePluginWarning(params: {
pluginId?: string;
reason: string;
}): PostUpdatePluginWarning {
const reason = params.reason.trim() || "unknown plugin post-update failure";
const guidance = [
POST_UPDATE_PLUGIN_REPAIR_GUIDANCE,
...(params.pluginId ? [formatPostUpdatePluginInspectGuidance(params.pluginId)] : []),
];
return {
...(params.pluginId ? { pluginId: params.pluginId } : {}),
reason,
message: params.pluginId
? `Plugin "${params.pluginId}" could not be processed after the core update: ${reason} ${guidance.join(" ")}`
: `Plugin post-update processing could not complete after the core update: ${reason} ${guidance.join(" ")}`,
guidance,
};
}
function createGuidedPostUpdatePluginOutcome(
outcome: PluginUpdateOutcome,
options: { includeWarningInReason?: boolean } = {},
): {
outcome: PluginUpdateOutcome;
warning?: PostUpdatePluginWarning;
} {
if (outcome.status !== "error" && !isActionableSkippedPostUpdateOutcome(outcome)) {
return { outcome };
}
const includeWarningInReason = options.includeWarningInReason ?? true;
const warningReason =
outcome.warning && includeWarningInReason
? `${outcome.warning}\n${outcome.message}`
: outcome.message;
const warning = createPostUpdatePluginWarning({
...(outcome.pluginId && outcome.pluginId !== "unknown" ? { pluginId: outcome.pluginId } : {}),
reason: warningReason,
});
return {
outcome: {
...outcome,
message: warning.message,
},
warning,
};
}
function collectPluginChannelFallbackMessages(outcomes: readonly PluginUpdateOutcome[]): string[] {
const seen = new Set<string>();
const messages: string[] = [];
for (const outcome of outcomes) {
const message = outcome.channelFallback?.message;
if (!message || seen.has(message)) {
continue;
}
seen.add(message);
messages.push(message);
}
return messages;
}
function isDisabledAfterFailureOutcome(outcome: PluginUpdateOutcome): boolean {
return outcome.status === "skipped" && outcome.message.includes("after plugin update failure");
}
function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boolean {
return isDisabledAfterFailureOutcome(outcome) || isClawHubTrustSkippedOutcome(outcome);
}
export async function updatePluginsAfterCoreUpdate(params: {
root: string;
channel: UpdateChannel;
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
configChanged?: boolean;
restoredAuthoredChannels?: unknown;
opts: UpdateCommandOptions;
timeoutMs: number;
pluginInstallRecords?: Record<string, PluginInstallRecord>;
}): Promise<PostCorePluginUpdateResult> {
if (!params.configSnapshot.valid) {
const invalid = buildInvalidConfigPostCoreUpdateResult();
if (!params.opts.json) {
defaultRuntime.log(theme.error(invalid.message));
for (const line of invalid.guidance) {
defaultRuntime.log(theme.muted(` ${line}`));
}
}
return invalid.result;
}
const clawHubTrustNotices = new Set<string>();
const loggedPluginWarnings = new Set<string>();
const hasLoggedPluginWarning = (message: string): boolean =>
loggedPluginWarnings.has(stripAnsi(message));
const recordLoggedPluginWarning = (message: string): void => {
loggedPluginWarnings.add(stripAnsi(message));
};
const recordClawHubTrustNotice = (message: string): void => {
const shouldRecord = params.opts.json
? isClawHubTrustNotice(message)
: isNonBlockingClawHubTrustNotice(message);
if (shouldRecord) {
clawHubTrustNotices.add(stripAnsi(message));
}
};
const pluginLogger = {
...(params.opts.json ? { terminalLinks: false } : {}),
info: (msg: string) => {
if (!params.opts.json) {
defaultRuntime.log(msg);
}
},
warn: (msg: string) => {
recordLoggedPluginWarning(msg);
recordClawHubTrustNotice(msg);
if (!params.opts.json) {
defaultRuntime.log(formatPluginUpdateWarning(msg));
}
},
error: (msg: string) => {
if (!params.opts.json) {
defaultRuntime.log(theme.error(msg));
}
},
};
if (!params.opts.json) {
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Updating plugins..."));
}
const warnings: PostUpdatePluginWarning[] = [];
const clawHubRiskAcknowledgementOptions = resolveUpdateClawHubRiskAcknowledgementOptions(
params.opts,
{
renderWarningBeforePrompt: (warning) => {
if (hasLoggedPluginWarning(warning)) {
return;
}
recordLoggedPluginWarning(warning);
recordClawHubTrustNotice(warning);
if (!params.opts.json) {
defaultRuntime.log(formatPluginUpdateWarning(warning));
}
},
},
);
const pluginInstallRecords =
params.pluginInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
const pluginUpdateChannel = params.channel;
const coreVersion = await readPackageVersion(params.root);
const syncConfig = withPluginInstallRecords(
params.configSnapshot.sourceConfig,
pluginInstallRecords,
);
const syncResult = await syncPluginsForUpdateChannel({
config: syncConfig,
channel: pluginUpdateChannel,
coreVersion: coreVersion ?? undefined,
workspaceDir: params.root,
externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({
workspaceDir: params.root,
}),
...clawHubRiskAcknowledgementOptions,
logger: pluginLogger,
});
for (const error of syncResult.summary.errors) {
warnings.push(createPostUpdatePluginWarning({ reason: error }));
}
let pluginConfig = syncResult.config;
const integrityDrifts: PostCorePluginUpdateResult["integrityDrifts"] = [];
const pluginUpdateOutcomes: PluginUpdateOutcome[] = [];
let pluginsChanged = syncResult.changed || params.configChanged === true;
let npmPluginsChanged = false;
const onPluginIntegrityDrift = async (drift: PluginUpdateIntegrityDriftParams) => {
integrityDrifts.push({
pluginId: drift.pluginId,
spec: drift.spec,
expectedIntegrity: drift.expectedIntegrity,
actualIntegrity: drift.actualIntegrity,
...(drift.resolvedSpec ? { resolvedSpec: drift.resolvedSpec } : {}),
...(drift.resolvedVersion ? { resolvedVersion: drift.resolvedVersion } : {}),
action: "aborted",
});
if (!params.opts.json) {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}` +
"\nPlugin update aborted. Reinstall the plugin only if you trust the new artifact.",
),
);
}
return false;
};
const collectMissingPayloadWarnings = async (
records: Record<string, PluginInstallRecord>,
): Promise<readonly string[]> => {
const missing = await collectMissingPluginInstallPayloads({
records,
config: pluginConfig,
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
});
if (missing.length === 0) {
return [];
}
const missingIds = missing.map((entry) => entry.pluginId);
for (const entry of missing) {
const warning = createPostUpdatePluginWarning({
pluginId: entry.pluginId,
reason: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
});
warnings.push(warning);
pluginUpdateOutcomes.push({
pluginId: entry.pluginId,
status: "error",
message: warning.message,
});
if (!params.opts.json) {
defaultRuntime.log(theme.warn(warning.message));
}
}
const repairResult = await updateNpmInstalledPlugins({
config: pluginConfig,
pluginIds: missingIds,
timeoutMs: params.timeoutMs,
updateChannel: pluginUpdateChannel,
coreVersion: coreVersion ?? undefined,
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
disableOnFailure: true,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
...clawHubRiskAcknowledgementOptions,
});
pluginConfig = repairResult.config;
pluginsChanged ||= repairResult.changed;
npmPluginsChanged ||= repairResult.changed;
pluginUpdateOutcomes.push(...repairResult.outcomes);
return missingIds;
};
const missingPayloadIdSet = new Set(await collectMissingPayloadWarnings(pluginInstallRecords));
const npmResult = await updateNpmInstalledPlugins({
config: pluginConfig,
timeoutMs: params.timeoutMs,
updateChannel: pluginUpdateChannel,
coreVersion: coreVersion ?? undefined,
skipIds: resolvePostSyncPluginUpdateSkipIds({
switchedToClawHub: syncResult.summary.switchedToClawHub,
switchedToNpm: syncResult.summary.switchedToNpm,
repairedMissingPayloadIds: missingPayloadIdSet,
}),
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
disableOnFailure: true,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
...clawHubRiskAcknowledgementOptions,
});
pluginConfig = npmResult.config;
pluginsChanged ||= npmResult.changed;
npmPluginsChanged ||= npmResult.changed;
for (const rawOutcome of npmResult.outcomes) {
const includeWarningInReason =
params.opts.json || !rawOutcome.warning || !hasLoggedPluginWarning(rawOutcome.warning);
const guided = createGuidedPostUpdatePluginOutcome(rawOutcome, { includeWarningInReason });
pluginUpdateOutcomes.push(guided.outcome);
if (guided.warning) {
warnings.push(guided.warning);
}
}
const remainingMissingPayloads = await collectMissingPluginInstallPayloads({
records: pluginConfig.plugins?.installs ?? {},
config: pluginConfig,
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
});
pluginUpdateOutcomes.push(
...remainingMissingPayloads
.filter((entry) => !missingPayloadIdSet.has(entry.pluginId))
.map((entry): PluginUpdateOutcome => {
const warning = createPostUpdatePluginWarning({
pluginId: entry.pluginId,
reason: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
});
warnings.push(warning);
return {
pluginId: entry.pluginId,
status: "error",
message: warning.message,
};
}),
);
// Mandatory post-core convergence: repair any configured plugin install
// records that are still missing payloads on disk and run a static smoke
// check that the repaired payloads are at least loadable. Failures here
// escalate `status` to `"error"`, which the caller maps to exit 1 BEFORE
// restarting the gateway. See `post-core-plugin-convergence.ts`.
//
// We pass `baselineInstallRecords: pluginConfig.plugins?.installs ?? {}`
// so that convergence layers its mutations on top of the latest
// *in-memory* sync/npm record state — not on the stale pre-update disk
// snapshot. The merged map convergence returns is the single source of
// truth for the subsequent commit block.
const convergenceBaselineRecords = pluginConfig.plugins?.installs ?? {};
const convergence = await runPostCorePluginConvergence({
cfg: pluginConfig,
env: process.env,
baselineInstallRecords: convergenceBaselineRecords,
...clawHubRiskAcknowledgementOptions,
});
for (const change of convergence.changes) {
if (!params.opts.json) {
defaultRuntime.log(theme.muted(change));
}
}
const convergenceFolded = convergenceWarningsToOutcomes(convergence);
for (const warning of convergenceFolded.warnings) {
warnings.push(warning);
if (!params.opts.json) {
defaultRuntime.log(theme.warn(warning.message));
for (const guidance of warning.guidance) {
defaultRuntime.log(theme.muted(` ${guidance}`));
}
}
}
pluginUpdateOutcomes.push(...convergenceFolded.outcomes);
const convergenceErrored = convergenceFolded.errored;
// Reseed `pluginConfig` from convergence's authoritative post-merge
// record map. This is unconditional because convergence is what
// reconciled the baseline (sync/npm in-memory state) with disk and any
// new repairs, and convergence already persisted that exact map. If
// we did not adopt it here, the commit block below would overwrite the
// disk with `convergenceBaselineRecords` (no repairs included).
pluginConfig = withPluginInstallRecords(pluginConfig, convergence.installRecords);
if (convergence.changes.length > 0) {
pluginsChanged = true;
}
if (pluginsChanged) {
const nextInstallRecords = pluginConfig.plugins?.installs ?? {};
let nextConfig = withoutPluginInstallRecords(pluginConfig);
if (params.restoredAuthoredChannels !== undefined) {
nextConfig = {
...nextConfig,
channels: structuredClone(params.restoredAuthoredChannels) as OpenClawConfig["channels"],
};
}
await commitPluginInstallRecordsWithConfig({
previousInstallRecords: pluginInstallRecords,
nextInstallRecords,
nextConfig,
baseHash: params.configSnapshot.hash,
});
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
reason: "source-changed",
workspaceDir: params.root,
installRecords: nextInstallRecords,
invalidateRuntimeCache: false,
logger: pluginLogger,
});
}
for (const notice of clawHubTrustNotices) {
if (warnings.some((warning) => warning.reason.includes(notice))) {
continue;
}
warnings.push({
reason: notice,
message: notice,
guidance: [],
});
}
if (params.opts.json) {
return {
status: convergenceErrored ? "error" : warnings.length > 0 ? "warning" : "ok",
changed: pluginsChanged,
warnings,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
switchedToNpm: syncResult.summary.switchedToNpm,
warnings: syncResult.summary.warnings,
errors: syncResult.summary.errors,
},
npm: {
changed: npmPluginsChanged,
outcomes: pluginUpdateOutcomes,
},
integrityDrifts,
};
}
const summarizeList = (list: string[]) => {
if (list.length <= 6) {
return list.join(", ");
}
return `${list.slice(0, 6).join(", ")} +${list.length - 6} more`;
};
if (syncResult.summary.switchedToBundled.length > 0) {
defaultRuntime.log(
theme.muted(
`Switched to bundled plugins: ${summarizeList(syncResult.summary.switchedToBundled)}.`,
),
);
}
if (syncResult.summary.switchedToNpm.length > 0) {
defaultRuntime.log(
theme.muted(`Restored npm plugins: ${summarizeList(syncResult.summary.switchedToNpm)}.`),
);
}
for (const warning of syncResult.summary.warnings) {
if (!hasLoggedPluginWarning(warning)) {
defaultRuntime.log(formatPluginUpdateWarning(warning));
}
}
for (const error of syncResult.summary.errors) {
defaultRuntime.log(theme.warn(createPostUpdatePluginWarning({ reason: error }).message));
}
const updated = pluginUpdateOutcomes.filter((entry) => entry.status === "updated").length;
const unchanged = pluginUpdateOutcomes.filter((entry) => entry.status === "unchanged").length;
const failed = pluginUpdateOutcomes.filter((entry) => entry.status === "error").length;
const skipped = pluginUpdateOutcomes.filter((entry) => entry.status === "skipped").length;
if (pluginUpdateOutcomes.length === 0) {
defaultRuntime.log(theme.muted("No plugin updates needed."));
} else {
const parts = [`${updated} updated`, `${unchanged} unchanged`];
if (failed > 0) {
parts.push(`${failed} failed`);
}
if (skipped > 0) {
parts.push(`${skipped} skipped`);
}
defaultRuntime.log(theme.muted(`npm plugins: ${parts.join(", ")}.`));
}
for (const message of collectPluginChannelFallbackMessages(pluginUpdateOutcomes)) {
defaultRuntime.log(theme.warn(message));
}
for (const outcome of pluginUpdateOutcomes) {
if (outcome.status !== "error" && !isActionableSkippedPostUpdateOutcome(outcome)) {
continue;
}
defaultRuntime.log(theme.warn(outcome.message));
}
return {
status: convergenceErrored ? "error" : warnings.length > 0 ? "warning" : "ok",
changed: pluginsChanged,
warnings,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
switchedToNpm: syncResult.summary.switchedToNpm,
warnings: syncResult.summary.warnings,
errors: syncResult.summary.errors,
},
npm: {
changed: npmPluginsChanged,
outcomes: pluginUpdateOutcomes,
},
integrityDrifts,
};
}