mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 06:59:29 +00:00
* 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
365 lines
15 KiB
TypeScript
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);
|
|
}
|
|
}
|