// Persistence helpers for plugin and hook-pack installs plus related config mutation. import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { replaceConfigFile } from "../config/config.js"; import { hashConfigIncludeRaw, readConfigIncludeFileWithGuards, resolveConfigIncludeWritePath, } from "../config/includes.js"; import type { ConfigWriteOptions } from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js"; import { isPathInside } from "../infra/path-guards.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { loadInstalledPluginIndexInstallRecords, recordPluginInstallInRecords, withoutPluginInstallRecords, } from "../plugins/installed-plugin-index-records.js"; import type { PluginInstallUpdate } from "../plugins/installs.js"; import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; import { buildPluginSnapshotReport } from "../plugins/status.js"; import { applyPluginUninstallDirectoryRemoval, planPluginUninstall, type PluginUninstallDirectoryRemoval, } from "../plugins/uninstall.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import { applySlotSelectionForPlugin, enableInternalHookEntries, logHookPackRestartHint, logSlotWarnings, } from "./plugins-command-helpers.js"; import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js"; function addInstalledPluginToAllowlist(cfg: OpenClawConfig, pluginId: string): OpenClawConfig { const allow = cfg.plugins?.allow; if (!Array.isArray(allow) || allow.length === 0 || allow.includes(pluginId)) { return cfg; } return { ...cfg, plugins: { ...cfg.plugins, // Preserve authored allowlist order so env-backed entries remain aligned // with the write-time env restoration snapshot. allow: [...allow, pluginId], }, }; } function removeInstalledPluginFromDenylist(cfg: OpenClawConfig, pluginId: string): OpenClawConfig { const deny = cfg.plugins?.deny; if (!Array.isArray(deny) || !deny.includes(pluginId)) { return cfg; } const nextDeny = deny.filter((id) => id !== pluginId); const plugins = { ...cfg.plugins, ...(nextDeny.length > 0 ? { deny: nextDeny } : {}), }; if (nextDeny.length === 0) { delete plugins.deny; } return { ...cfg, plugins, }; } export type ConfigSnapshotForInstallPersist = { config: OpenClawConfig; baseHash: string | undefined; writeOptions: Pick< ConfigWriteOptions, | "assertConfigPathForWrite" | "expectedConfigPath" | "ownedConfigPathForWrite" | "envSnapshotForRestore" | "includeFileHashesForWrite" | "includeFileTargetsForWrite" >; }; type ConfigMutationSection = "hooks" | "plugins"; export type ConfigMutationPreflight = | { mode: "allowed" } | { mode: "blocked"; scope: "config" | ConfigMutationSection; reason: string }; const CONFIG_MUTATION_ALLOWED = { mode: "allowed" } as const; export function containsConfigIncludeDirective(value: unknown): boolean { if (Array.isArray(value)) { return value.some((entry) => containsConfigIncludeDirective(entry)); } if (!isRecord(value)) { return false; } return ( Object.hasOwn(value, "$include") || Object.values(value).some((entry) => containsConfigIncludeDirective(entry)) ); } export function supportsInstallConfigSingleTopLevelIncludeShape(authoredSection: unknown): boolean { if (!containsConfigIncludeDirective(authoredSection)) { return true; } return ( isRecord(authoredSection) && Object.keys(authoredSection).length === 1 && typeof authoredSection.$include === "string" ); } function resolveSingleTopLevelIncludePath( parsed: Record, configPath: string, section: ConfigMutationSection, ): string | null { const authoredSection = parsed[section]; if ( !isRecord(authoredSection) || Object.keys(authoredSection).length !== 1 || typeof authoredSection.$include !== "string" ) { return null; } return path.normalize( path.isAbsolute(authoredSection.$include) ? authoredSection.$include : path.resolve(path.dirname(configPath), authoredSection.$include), ); } function resolveConfigMutationPreflight(params: { parsed: Record; section: ConfigMutationSection; snapshotPath: string; writeOptions: ConfigSnapshotForInstallPersist["writeOptions"]; }): ConfigMutationPreflight { if (Object.hasOwn(params.parsed, "$include")) { return { mode: "blocked", scope: "config", reason: `Config ${params.section} are stored through an unsupported $include shape at the root; edit the included file directly or move ${params.section} into the root config before installing.`, }; } if (!supportsInstallConfigSingleTopLevelIncludeShape(params.parsed[params.section])) { return { mode: "blocked", scope: params.section, reason: `Config ${params.section} are stored through an unsupported $include shape; edit the included file directly or move ${params.section} to a single-file top-level include before installing.`, }; } const includePath = resolveSingleTopLevelIncludePath( params.parsed, params.snapshotPath, params.section, ); if (!includePath) { return CONFIG_MUTATION_ALLOWED; } const expectedTarget = params.writeOptions.includeFileTargetsForWrite?.[includePath]; let resolvedTarget: string | null = null; try { resolvedTarget = resolveConfigIncludeWritePath({ configPath: params.snapshotPath, includePath, allowedRoots: [], }); } catch { // The persistence path rejects includes that are no longer root-bound too. } if ( expectedTarget && resolvedTarget && path.normalize(expectedTarget) === path.normalize(resolvedTarget) ) { const expectedHash = params.writeOptions.includeFileHashesForWrite?.[includePath]; try { const raw = readConfigIncludeFileWithGuards({ includePath, resolvedPath: resolvedTarget, rootRealDir: fs.realpathSync(path.dirname(params.snapshotPath)), }); if (expectedHash !== hashConfigIncludeRaw(raw)) { return { mode: "blocked", scope: params.section, reason: `Config ${params.section} include changed since the config was read; rerun the install after reloading the config.`, }; } if (containsConfigIncludeDirective(parseJsonWithJson5Fallback(raw))) { return { mode: "blocked", scope: params.section, reason: `Config ${params.section} are stored through a nested $include; edit the included file directly or remove the nested $include before installing.`, }; } return CONFIG_MUTATION_ALLOWED; } catch { return { mode: "blocked", scope: params.section, reason: `Config ${params.section} include could not be inspected at its snapshot target; rerun the install after repairing or reloading the config.`, }; } } return { mode: "blocked", scope: params.section, reason: `Config ${params.section} are stored in an external or unresolved top-level $include; edit the included file directly or move it under the config directory before installing.`, }; } export function resolveInstallConfigMutationPreflights(params: { parsed: Record; snapshotPath: string; writeOptions: ConfigSnapshotForInstallPersist["writeOptions"]; }): { hookMutation: ConfigMutationPreflight; pluginMutation: ConfigMutationPreflight; } { const pluginMutation = resolveConfigMutationPreflight({ ...params, section: "plugins", }); const hookMutation = resolveConfigMutationPreflight({ ...params, section: "hooks", }); const pluginIncludePath = resolveSingleTopLevelIncludePath( params.parsed, params.snapshotPath, "plugins", ); const hookIncludePath = resolveSingleTopLevelIncludePath( params.parsed, params.snapshotPath, "hooks", ); const pluginTarget = pluginIncludePath ? params.writeOptions.includeFileTargetsForWrite?.[pluginIncludePath] : undefined; const hookTarget = hookIncludePath ? params.writeOptions.includeFileTargetsForWrite?.[hookIncludePath] : undefined; if (pluginTarget && hookTarget && path.normalize(pluginTarget) === path.normalize(hookTarget)) { const blocked = { mode: "blocked", scope: "config", reason: "Config plugins and hooks share the same top-level $include target; split them into separate include files before installing.", } as const; return { hookMutation: blocked, pluginMutation: blocked }; } return { hookMutation, pluginMutation }; } export function resolveCombinedPluginAndHookConfigMutationPreflight(params: { parsed: Record; snapshotPath: string; }): ConfigMutationPreflight { const pluginIncludePath = resolveSingleTopLevelIncludePath( params.parsed, params.snapshotPath, "plugins", ); const hookIncludePath = resolveSingleTopLevelIncludePath( params.parsed, params.snapshotPath, "hooks", ); if (!pluginIncludePath && !hookIncludePath) { return CONFIG_MUTATION_ALLOWED; } return { mode: "blocked", scope: "config", reason: "Config plugins and hooks cannot be updated together while either section uses a top-level $include; update them separately.", }; } export function selectInstallMutationWriteOptions( writeOptions: ConfigWriteOptions, ): ConfigSnapshotForInstallPersist["writeOptions"] { // Install work may outlive its config read. Keep only mutation-start ownership // and conflict facts; plugin metadata must come from the commit-time read. return { ...(writeOptions.assertConfigPathForWrite ? { assertConfigPathForWrite: writeOptions.assertConfigPathForWrite } : {}), expectedConfigPath: writeOptions.expectedConfigPath, ownedConfigPathForWrite: writeOptions.ownedConfigPathForWrite, envSnapshotForRestore: writeOptions.envSnapshotForRestore, includeFileHashesForWrite: writeOptions.includeFileHashesForWrite, includeFileTargetsForWrite: writeOptions.includeFileTargetsForWrite, }; } function sourceMatchesInstalledPath(params: { activeSource: string; installedSource: string; env?: NodeJS.ProcessEnv; }): boolean { const activeSource = resolveUserPath(params.activeSource, params.env); const installedSource = resolveUserPath(params.installedSource, params.env); return activeSource === installedSource || isPathInside(installedSource, activeSource); } function logShadowedNpmInstallWarning(params: { config: OpenClawConfig; pluginId: string; install: Omit; runtime: RuntimeEnv; }): void { // Warn when a newly installed npm plugin is shadowed by an explicit config source. if (params.install.source !== "npm") { return; } const installedSource = params.install.installPath ?? params.install.sourcePath; if (!installedSource) { return; } const report = buildPluginSnapshotReport({ config: params.config, effectiveOnly: true, onlyPluginIds: [params.pluginId], }); const active = report.plugins.find((plugin) => plugin.id === params.pluginId); if ( !active || active.origin !== "config" || sourceMatchesInstalledPath({ activeSource: active.source, installedSource }) ) { return; } params.runtime.log( theme.warn( [ `Warning: installed plugin "${params.pluginId}" is not the active source because a config-selected plugin with the same id is currently selected:`, ` active config source: ${shortenHomePath(active.source)}`, ` installed npm source: ${shortenHomePath(installedSource)}`, "Run `openclaw plugins doctor` for repair options.", ].join("\n"), ), ); } function resolveComparableInstallPath( install: Pick, ) { return install.installPath ?? install.sourcePath; } function shouldPreserveReplacedInstallPath(params: { removalTarget: string; nextInstallPath: string; }) { const removalTarget = resolveUserPath(params.removalTarget); const nextInstallPath = resolveUserPath(params.nextInstallPath); return ( isPathInside(removalTarget, nextInstallPath) || isPathInside(nextInstallPath, removalTarget) ); } function resolveReplacedManagedInstallRemoval(params: { pluginId: string; previousInstall?: PluginInstallRecord; nextInstall: Omit; }): PluginUninstallDirectoryRemoval | null { if (!params.previousInstall) { return null; } const previousInstallPath = resolveComparableInstallPath(params.previousInstall); const nextInstallPath = resolveComparableInstallPath(params.nextInstall); if (!previousInstallPath || !nextInstallPath) { return null; } if (params.previousInstall.source === "npm" && params.nextInstall.source === "npm") { // npm plugin updates can leave a running gateway holding imports into the // previous dist tree until restart; keep replaced generations available. return null; } if ( shouldPreserveReplacedInstallPath({ removalTarget: previousInstallPath, nextInstallPath, }) ) { return null; } const plan = planPluginUninstall({ config: { plugins: { installs: { [params.pluginId]: params.previousInstall, }, }, } as OpenClawConfig, pluginId: params.pluginId, deleteFiles: true, }); if (!plan.ok || !plan.directoryRemoval) { return null; } if ( shouldPreserveReplacedInstallPath({ removalTarget: plan.directoryRemoval.target, nextInstallPath, }) ) { return null; } return plan.directoryRemoval; } export async function persistPluginInstall(params: { snapshot: ConfigSnapshotForInstallPersist; pluginId: string; install: Omit; enable?: boolean; invalidateRuntimeCache?: boolean; successMessage?: string; warningMessage?: string; runtime?: RuntimeEnv; }): Promise { const runtime = params.runtime ?? defaultRuntime; const installConfig = params.enable === false ? params.snapshot.config : removeInstalledPluginFromDenylist( addInstalledPluginToAllowlist(params.snapshot.config, params.pluginId), params.pluginId, ); let next = params.enable === false ? installConfig : enablePluginInConfig(installConfig, params.pluginId, { updateChannelConfig: false, }).config; const installRecords = await tracePluginLifecyclePhaseAsync( "install records load", () => loadInstalledPluginIndexInstallRecords(), { command: "install" }, ); const previousInstall = installRecords[params.pluginId]; const replacedInstallRemoval = resolveReplacedManagedInstallRemoval({ pluginId: params.pluginId, previousInstall, nextInstall: params.install, }); const nextInstallRecords = recordPluginInstallInRecords(installRecords, { pluginId: params.pluginId, ...params.install, }); const slotResult = params.enable === false ? { config: next, warnings: [] } : await tracePluginLifecyclePhaseAsync( "slot selection", async () => applySlotSelectionForPlugin(next, params.pluginId), { command: "install", pluginId: params.pluginId }, ); next = withoutPluginInstallRecords(slotResult.config); await tracePluginLifecyclePhaseAsync( "config mutation", () => commitPluginInstallRecordsWithConfig({ previousInstallRecords: installRecords, nextInstallRecords, nextConfig: next, baseHash: params.snapshot.baseHash, writeOptions: { ...params.snapshot.writeOptions, afterWrite: { mode: "restart", reason: "plugin source changed" }, }, }), { command: "install" }, ); if (replacedInstallRemoval) { const removalResult = await tracePluginLifecyclePhaseAsync( "replaced install cleanup", () => applyPluginUninstallDirectoryRemoval(replacedInstallRemoval), { command: "install", pluginId: params.pluginId }, ); for (const warning of removalResult.warnings) { runtime.log(theme.warn(warning)); } if (removalResult.directoryRemoved) { runtime.log( theme.muted( `Removed previous plugin install directory: ${shortenHomePath(replacedInstallRemoval.target)}`, ), ); } } await refreshPluginRegistryAfterConfigMutation({ config: next, reason: "source-changed", installRecords: nextInstallRecords, invalidateRuntimeCache: params.invalidateRuntimeCache, traceCommand: "install", logger: { warn: (message) => runtime.log(theme.warn(message)), }, }); logSlotWarnings(slotResult.warnings, runtime); if (params.warningMessage) { runtime.log(theme.warn(params.warningMessage)); } runtime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`); logShadowedNpmInstallWarning({ config: next, pluginId: params.pluginId, install: params.install, runtime, }); runtime.log("Restart the gateway to load plugins."); return next; } export async function persistHookPackInstall(params: { snapshot: ConfigSnapshotForInstallPersist; hookPackId: string; hooks: string[]; install: Omit; successMessage?: string; runtime?: RuntimeEnv; }): Promise { const runtime = params.runtime ?? defaultRuntime; let next = enableInternalHookEntries(params.snapshot.config, params.hooks); next = recordHookInstall(next, { hookId: params.hookPackId, hooks: params.hooks, ...params.install, }); await replaceConfigFile({ nextConfig: next, baseHash: params.snapshot.baseHash, writeOptions: params.snapshot.writeOptions, }); runtime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`); logHookPackRestartHint(runtime); return next; }