Files
openclaw/src/cli/plugins-update-command.ts
Vincent Koc 767e8280ac fix(cli): harden official plugin recovery (#93325)
* fix(cli): harden official plugin recovery

* fix(config): preserve include write context

* fix(config): reject external include mutations

* fix(config): bind snapshots to config paths

* fix(config): preserve write ownership

* fix(cli): preflight plugin config mutations

* chore(plugin-sdk): refresh api baseline

* test(config): prove install env policy mutations

* fix(cli): preflight plugin updates

* fix(cli): preflight non-npm id migrations

* chore(plugin-sdk): refresh api baseline

* fix(cli): satisfy plugin recovery checks
2026-06-15 23:07:29 +08:00

365 lines
15 KiB
TypeScript

// `openclaw plugins update` command implementation for tracked npm plugins and hook packs.
import { theme } from "../../packages/terminal-core/src/theme.js";
import {
assertConfigWriteAllowedInCurrentMode,
getRuntimeConfig,
readConfigFileSnapshotForWrite,
replaceConfigFile,
} from "../config/config.js";
import { createMergePatch } from "../config/io.write-prepare.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { extractShippedPluginInstallConfigRecords } from "../config/plugin-install-config-migration.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { updateNpmInstalledHookPacks } from "../hooks/update.js";
import {
loadInstalledPluginIndexInstallRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../plugins/installed-plugin-index-records.js";
import {
isPluginInstallRecordUpdateSource,
pluginInstallRecordMayMigrateConfigId,
updateNpmInstalledPlugins,
} from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js";
import {
containsConfigIncludeDirective,
resolveCombinedPluginAndHookConfigMutationPreflight,
resolveInstallConfigMutationPreflights,
selectInstallMutationWriteOptions,
} from "./plugins-install-persist.js";
import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js";
import { logPluginUpdateOutcomes } from "./plugins-update-outcomes.js";
import {
resolveHookPackUpdateSelection,
resolvePluginUpdateSelection,
} from "./plugins-update-selection.js";
import { promptYesNo } from "./prompt.js";
const DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING =
"--dangerously-force-unsafe-install is deprecated and no longer affects plugin updates because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions.";
function mayMutatePluginInstallRecord(
record: PluginInstallRecord | undefined,
specOverride: string | undefined,
): boolean {
if (!isPluginInstallRecordUpdateSource(record)) {
return false;
}
if (record?.source === "npm") {
return Boolean(specOverride ?? record.spec);
}
if (record?.source === "git") {
return Boolean(record.spec);
}
if (record?.source === "clawhub") {
return Boolean(record.clawhubPackage);
}
return Boolean(record?.marketplaceSource && record.marketplacePlugin);
}
function pluginConfigReferencesId(config: ReturnType<typeof getRuntimeConfig>, pluginId: string) {
const plugins = config.plugins;
return (
plugins?.allow?.includes(pluginId) ||
plugins?.deny?.includes(pluginId) ||
Object.hasOwn(plugins?.entries ?? {}, pluginId) ||
plugins?.slots?.memory === pluginId ||
plugins?.slots?.contextEngine === pluginId
);
}
function shouldPreserveEmptyPlugins(params: {
parsed: unknown;
sourceConfig: ReturnType<typeof getRuntimeConfig>;
}): boolean {
const plugins = params.sourceConfig.plugins;
const parsedPlugins =
params.parsed && typeof params.parsed === "object" && !Array.isArray(params.parsed)
? (params.parsed as Record<string, unknown>).plugins
: undefined;
return Boolean(
plugins &&
(!Object.hasOwn(plugins, "installs") ||
Object.keys(plugins).some((key) => key !== "installs") ||
containsConfigIncludeDirective(parsedPlugins)),
);
}
function projectUpdaterResultOntoSourceConfig(params: {
runtimeBase: OpenClawConfig;
sourceBase: OpenClawConfig;
updatedConfig: OpenClawConfig;
}): OpenClawConfig {
const updatePatch = createMergePatch(params.runtimeBase, params.updatedConfig);
return applyMergePatch(params.sourceBase, updatePatch) as OpenClawConfig;
}
/** Run plugin/hook-pack updates, persist changed install records, and refresh runtime registry. */
export async function runPluginUpdateCommand(params: {
id?: string;
opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean };
}) {
assertConfigWriteAllowedInCurrentMode();
const sourceSnapshotPromise = readConfigFileSnapshotForWrite()
.then((prepared) => ({
...prepared,
writeOptions: selectInstallMutationWriteOptions(prepared.writeOptions),
}))
.catch(() => null);
const mutationSnapshot = params.opts.dryRun ? null : await sourceSnapshotPromise;
if (!params.opts.dryRun && !mutationSnapshot) {
defaultRuntime.error("Could not inspect config ownership before updating plugins or hooks.");
return defaultRuntime.exit(1);
}
if (mutationSnapshot && !mutationSnapshot.snapshot.valid) {
defaultRuntime.error("Cannot update plugins or hooks while the config is invalid.");
return defaultRuntime.exit(1);
}
// Bind selection, updater input, ownership checks, and persistence to one
// mutation-start snapshot so concurrent config changes cannot be resurrected.
const cfg = mutationSnapshot?.snapshot.runtimeConfig ?? getRuntimeConfig();
const sourceCfg = mutationSnapshot?.snapshot.sourceConfig ?? cfg;
const shippedPluginInstallRecords = mutationSnapshot
? {
...extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.parsed),
...extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.sourceConfig),
}
: extractShippedPluginInstallConfigRecords(cfg);
const persistedPluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
// Persisted index records win over shipped legacy config during migration.
const pluginInstallRecords = {
...shippedPluginInstallRecords,
...persistedPluginInstallRecords,
};
const cfgWithPluginInstallRecords = withPluginInstallRecords(cfg, pluginInstallRecords);
const sourceCfgWithPluginInstallRecords = withPluginInstallRecords(
sourceCfg,
pluginInstallRecords,
);
const logger = {
info: (msg: string) => defaultRuntime.log(msg),
warn: (msg: string) => defaultRuntime.log(theme.warn(msg)),
};
if (params.opts.dangerouslyForceUnsafeInstall) {
defaultRuntime.log(theme.warn(DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING));
}
const pluginSelection = resolvePluginUpdateSelection({
installs: pluginInstallRecords,
rawId: params.id,
all: params.opts.all,
});
const hookSelection = resolveHookPackUpdateSelection({
installs: cfg.hooks?.internal?.installs ?? {},
rawId: params.id,
all: params.opts.all,
});
if (pluginSelection.pluginIds.length === 0 && hookSelection.hookIds.length === 0) {
if (params.opts.all) {
defaultRuntime.log("No tracked plugins or hook packs to update.");
return;
}
defaultRuntime.error("Provide a plugin or hook-pack id, or use --all.");
return defaultRuntime.exit(1);
}
const selectedHooks = cfg.hooks?.internal?.installs ?? {};
const pluginUpdateMayMutate =
!params.opts.dryRun &&
pluginSelection.pluginIds.some((pluginId) => {
return mayMutatePluginInstallRecord(
pluginInstallRecords[pluginId],
pluginSelection.specOverrides?.[pluginId],
);
});
const hookUpdateMayMutate =
!params.opts.dryRun &&
hookSelection.hookIds.some((hookId) => {
const record = selectedHooks[hookId];
return (
record?.source === "npm" && Boolean(hookSelection.specOverrides?.[hookId] ?? record.spec)
);
});
if (pluginUpdateMayMutate || hookUpdateMayMutate) {
if (!mutationSnapshot) {
defaultRuntime.error("Could not inspect config ownership before updating plugins or hooks.");
return defaultRuntime.exit(1);
}
const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({
parsed: (mutationSnapshot.snapshot.parsed ?? {}) as Record<string, unknown>,
snapshotPath: mutationSnapshot.snapshot.path,
writeOptions: mutationSnapshot.writeOptions,
});
// Write snapshots retain valid shipped install records in sourceConfig after
// include resolution; parsed also catches root-authored legacy records.
const pluginRecordCleanupMayMutate =
Object.keys(extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.sourceConfig))
.length > 0 ||
Object.keys(extractShippedPluginInstallConfigRecords(mutationSnapshot.snapshot.parsed))
.length > 0;
const parsedConfig =
mutationSnapshot.snapshot.parsed &&
typeof mutationSnapshot.snapshot.parsed === "object" &&
!Array.isArray(mutationSnapshot.snapshot.parsed)
? (mutationSnapshot.snapshot.parsed as Record<string, unknown>)
: {};
const pluginReferencesMayBeUnresolved =
Object.hasOwn(parsedConfig, "$include") ||
containsConfigIncludeDirective(mutationSnapshot.snapshot.sourceConfig.plugins);
const pluginIdMigrationMayMutate = pluginSelection.pluginIds.some((pluginId) => {
return (
pluginInstallRecordMayMigrateConfigId({
pluginId,
record: pluginInstallRecords[pluginId],
specOverride: pluginSelection.specOverrides?.[pluginId],
}) &&
(pluginReferencesMayBeUnresolved ||
pluginConfigReferencesId(mutationSnapshot.snapshot.sourceConfig, pluginId))
);
});
// Manual update records stay in the index unless shipped-record cleanup or
// scoped-package compatibility migrates authored references from a legacy id.
const pluginConfigMayMutate = pluginRecordCleanupMayMutate || pluginIdMigrationMayMutate;
const blockedReasons = new Set<string>();
if (pluginConfigMayMutate && pluginMutation.mode === "blocked") {
blockedReasons.add(pluginMutation.reason);
}
if (hookUpdateMayMutate && hookMutation.mode === "blocked") {
blockedReasons.add(hookMutation.reason);
}
if (
pluginConfigMayMutate &&
hookUpdateMayMutate &&
pluginMutation.mode === "allowed" &&
hookMutation.mode === "allowed"
) {
// Config persistence can commit one include-owned top-level section, not
// a mixed plugin-and-hook mutation spanning root and include ownership.
const combinedMutation = resolveCombinedPluginAndHookConfigMutationPreflight({
parsed: (mutationSnapshot.snapshot.parsed ?? {}) as Record<string, unknown>,
snapshotPath: mutationSnapshot.snapshot.path,
});
if (combinedMutation.mode === "blocked") {
blockedReasons.add(combinedMutation.reason);
}
}
if (blockedReasons.size > 0) {
defaultRuntime.error(Array.from(blockedReasons).join(" "));
return defaultRuntime.exit(1);
}
}
const pluginResult =
pluginSelection.pluginIds.length > 0
? await updateNpmInstalledPlugins({
config: cfgWithPluginInstallRecords,
pluginIds: pluginSelection.pluginIds,
specOverrides: pluginSelection.specOverrides,
dryRun: params.opts.dryRun,
dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall,
logger,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
if (drift.dryRun) {
return true;
}
return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`);
},
})
: { config: cfgWithPluginInstallRecords, changed: false, outcomes: [] };
const hookResult =
hookSelection.hookIds.length > 0
? await updateNpmInstalledHookPacks({
config: pluginResult.config,
hookIds: hookSelection.hookIds,
specOverrides: hookSelection.specOverrides,
dryRun: params.opts.dryRun,
logger,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for hook pack "${drift.hookId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
if (drift.dryRun) {
return true;
}
return await promptYesNo(
`Continue updating hook pack "${drift.hookId}" with this artifact?`,
);
},
})
: { config: pluginResult.config, changed: false, outcomes: [] };
const outcomeSummary = logPluginUpdateOutcomes({
outcomes: [...pluginResult.outcomes, ...hookResult.outcomes],
log: (message) => defaultRuntime.log(message),
});
if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) {
const sourceSnapshot = mutationSnapshot ?? (await sourceSnapshotPromise);
const nextPluginInstallRecords = pluginResult.config.plugins?.installs ?? {};
const shouldPersistPluginInstallIndex =
pluginResult.changed || Object.keys(pluginInstallRecords).length > 0;
const sourceShapedUpdateConfig = projectUpdaterResultOntoSourceConfig({
runtimeBase: cfgWithPluginInstallRecords,
sourceBase: sourceCfgWithPluginInstallRecords,
updatedConfig: hookResult.config,
});
// Plugin install records live in the persisted index. Preserve an authored
// empty plugins section so include ownership does not become a false mutation.
const nextConfig = withoutPluginInstallRecords(sourceShapedUpdateConfig, {
preserveEmptyPlugins: shouldPreserveEmptyPlugins({
parsed: sourceSnapshot?.snapshot.parsed,
sourceConfig: sourceSnapshot?.snapshot.sourceConfig ?? {},
}),
});
if (shouldPersistPluginInstallIndex) {
await commitPluginInstallRecordsWithConfig({
previousInstallRecords: persistedPluginInstallRecords,
nextInstallRecords: nextPluginInstallRecords,
nextConfig,
baseHash: sourceSnapshot?.snapshot.hash,
writeOptions: {
...sourceSnapshot?.writeOptions,
afterWrite: { mode: "restart", reason: "plugin source changed" },
},
});
} else {
await replaceConfigFile({
nextConfig,
baseHash: sourceSnapshot?.snapshot.hash,
writeOptions: sourceSnapshot?.writeOptions,
});
}
if (pluginResult.changed) {
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
reason: "source-changed",
installRecords: nextPluginInstallRecords,
invalidateRuntimeCache: false,
logger,
});
}
defaultRuntime.log("Restart the gateway to load plugins and hooks.");
}
if (outcomeSummary.hasErrors) {
defaultRuntime.exit(1);
}
}