openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
409 lines (408 loc) • 21 kB
JavaScript
import { f as withPluginCache, i as createPluginCache } from "./plugin-cache-DGWspMEc.js";
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js";
import { c as resolveUserPath } from "./home-dir-BPhrG-aM.js";
import { o as safeRealpathSync } from "./path-safety-Bi0ppMWC.js";
import { A as resolveConfigIncludeWritePath, O as hashConfigIncludeRaw, k as readConfigIncludeFileWithGuards } from "./redact-BtvPPfTi.js";
import { r as isPathInside } from "./path-guards-Cp-mGr3-.js";
import { m as shortenHomePath } from "./utils-P__uGsPB.js";
import { t as parseJsonWithJson5Fallback } from "./parse-json-compat-Cc8PAOTi.js";
import { r as validatePluginSchemaValue } from "./schema-validator-CLfGeb79.js";
import { a as loadInstalledPluginIndex } from "./installed-plugin-index-wrDsjyMD.js";
import { c as tracePluginLifecyclePhaseAsync, n as discoverOpenClawPlugins, p as resolvePluginCandidateInstallOwner, u as isPluginCandidateInstallOwnerAmbiguous } from "./discovery-D_VDsZuY.js";
import "./path-safety-BT3PFs5V.js";
import { a as isPluginManifestInstallOwnerAmbiguous, n as loadPluginManifestRegistryCore, o as resolvePluginManifestInstallOwner } from "./manifest-registry-DCCgYk7q.js";
import { r as loadInstalledPluginIndexInstallRecords, s as clearLoadInstalledPluginIndexInstallRecordsCache } from "./installed-plugin-index-record-reader-SXWwf_BU.js";
import { a as loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot-w-4EjxI4.js";
import { r as containsConfigIncludeDirective } from "./io.read-helpers-ZKp-UiGx.js";
import { n as enablePluginInConfig } from "./enable-3OwjFbBV.js";
import { r as theme } from "./theme-vjDs9tao.js";
import { d as reconcileNpmPluginLoadPath, n as recordPluginInstallInRecords, o as withoutPluginInstallRecords } from "./installed-plugin-index-records-C06Xozeq.js";
import { n as recordPluginPackageUninstallPlan } from "./uninstall-package-plan-BcbmXONg.js";
import { a as planPluginUninstall, n as applyPluginUninstallDirectoryRemoval } from "./uninstall-CTOrqce4.js";
import { i as commitPluginInstallRecordsWithConfig } from "./install-record-commit-C4pioTsf.js";
import { n as refreshPluginRegistryAfterConfigMutation } from "./registry-refresh-CvO7IUdQ.js";
import { s as buildPluginSnapshotReport } from "./status-DornsoQ9.js";
import { t as applySlotSelectionForPlugin } from "./slot-selection-icx6WfxG.js";
import fs from "node:fs";
import path from "node:path";
//#region src/plugins/install-persistence.ts
function addInstalledPluginToAllowlist(cfg, pluginId) {
const allow = cfg.plugins?.allow;
if (!Array.isArray(allow) || allow.length === 0 || allow.includes(pluginId)) return cfg;
return {
...cfg,
plugins: {
...cfg.plugins,
allow: [...allow, pluginId]
}
};
}
function removeInstalledPluginFromDenylist(cfg, pluginId) {
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
};
}
const CONFIG_MUTATION_ALLOWED = { mode: "allowed" };
function supportsInstallConfigSingleTopLevelIncludeShape(authoredSection) {
if (!containsConfigIncludeDirective(authoredSection)) return true;
return isRecord(authoredSection) && Object.keys(authoredSection).length === 1 && typeof authoredSection.$include === "string";
}
function resolveSingleTopLevelIncludePath(parsed, configPath, section) {
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) {
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 = null;
try {
resolvedTarget = resolveConfigIncludeWritePath({
configPath: params.snapshotPath,
includePath,
allowedRoots: []
});
} catch {}
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.`
};
}
function resolveInstallConfigMutationPreflights(params) {
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] : void 0;
const hookTarget = hookIncludePath ? params.writeOptions.includeFileTargetsForWrite?.[hookIncludePath] : void 0;
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."
};
return {
hookMutation: blocked,
pluginMutation: blocked
};
}
return {
hookMutation,
pluginMutation
};
}
function resolveCombinedPluginAndHookConfigMutationPreflight(params) {
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."
};
}
function selectInstallMutationWriteOptions(writeOptions) {
return {
auditOrigin: "plugin-install",
...writeOptions.assertConfigPathForWrite ? { assertConfigPathForWrite: writeOptions.assertConfigPathForWrite } : {},
expectedConfigPath: writeOptions.expectedConfigPath,
ownedConfigPathForWrite: writeOptions.ownedConfigPathForWrite,
envSnapshotForRestore: writeOptions.envSnapshotForRestore,
includeFileHashesForWrite: writeOptions.includeFileHashesForWrite,
includeFileTargetsForWrite: writeOptions.includeFileTargetsForWrite
};
}
function sourceMatchesInstalledPath(params) {
const activeSource = resolveUserPath(params.activeSource, params.env);
const installedSource = resolveUserPath(params.installedSource, params.env);
return activeSource === installedSource || isPathInside(installedSource, activeSource);
}
function logShadowedNpmInstallWarning(params) {
if (params.install.source !== "npm") return;
const installedSource = params.install.installPath ?? params.install.sourcePath;
if (!installedSource) return;
const active = buildPluginSnapshotReport({
config: params.config,
effectiveOnly: true,
onlyPluginIds: [params.pluginId]
}).plugins.find((plugin) => plugin.id === params.pluginId);
if (!active || active.origin !== "config" || sourceMatchesInstalledPath({
activeSource: active.source,
installedSource
})) return;
params.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"), `Installed plugin "${params.pluginId}" is shadowed by a configured plugin source. Run \`openclaw plugins doctor\`.`);
}
function resolveComparableInstallPath(install) {
return install.installPath ?? install.sourcePath;
}
function shouldPreserveReplacedInstallPath(params) {
const removalTarget = resolveUserPath(params.removalTarget);
const nextInstallPath = resolveUserPath(params.nextInstallPath);
return isPathInside(removalTarget, nextInstallPath) || isPathInside(nextInstallPath, removalTarget);
}
function resolveReplacedManagedInstallRemoval(params) {
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") return null;
if (shouldPreserveReplacedInstallPath({
removalTarget: previousInstallPath,
nextInstallPath
})) return null;
const plan = planPluginUninstall(recordPluginPackageUninstallPlan({
config: { plugins: { installs: { [params.pluginId]: params.previousInstall } } },
pluginId: params.pluginId,
deleteFiles: true
}, { runtimePluginIds: [] }));
if (!plan.ok || !plan.directoryRemoval) return null;
if (shouldPreserveReplacedInstallPath({
removalTarget: plan.directoryRemoval.target,
nextInstallPath
})) return null;
return plan.directoryRemoval;
}
function prepareConfigForDisabledInstall(config, pluginId) {
const entry = config.plugins?.entries?.[pluginId];
const policy = isRecord(entry) ? { ...entry } : {};
delete policy.config;
return {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[pluginId]: {
...policy,
enabled: false
}
}
}
};
}
function resolvePluginConfigEnablement(params) {
const manifest = params.manifest;
if (!manifest?.configSchema) return { mode: "ready" };
const entry = params.config.plugins?.entries?.[params.pluginId];
const hasConfig = isRecord(entry) && Object.hasOwn(entry, "config");
const result = validatePluginSchemaValue({
origin: manifest.origin,
schema: manifest.configSchema,
cacheKey: manifest.schemaCacheKey ?? manifest.manifestPath,
value: hasConfig ? entry.config : {},
applyDefaults: true
});
if (result.ok) return { mode: "ready" };
if (!hasConfig && !result.schemaError) return { mode: "missing" };
return {
mode: "invalid",
error: result.errors[0]?.text ?? "invalid plugin config"
};
}
async function persistPluginInstall(params) {
const installRecords = await tracePluginLifecyclePhaseAsync("install records load", () => loadInstalledPluginIndexInstallRecords(), { command: "install" });
try {
return await withPluginCache(createPluginCache(), async () => {
const runtime = params.runtime ?? defaultRuntime;
const warn = (message, managementMessage) => {
params.persistenceLogger?.warn?.(managementMessage);
runtime.log(theme.warn(message));
};
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 reconciledConfig = reconcileNpmPluginLoadPath({
config: params.snapshot.config,
previousInstall,
nextInstall: params.install
});
const installedDiscovery = discoverOpenClawPlugins({ installRecords: nextInstallRecords });
const realpathCache = /* @__PURE__ */ new Map();
const targetPathKeys = new Set([params.install.installPath, params.install.sourcePath].filter((candidate) => Boolean(candidate?.trim())).map((candidate) => {
const resolved = resolveUserPath(candidate, process.env);
return safeRealpathSync(resolved, realpathCache) ?? path.resolve(resolved);
}));
const installedCandidates = installedDiscovery.candidates.filter((candidate) => {
if (resolvePluginCandidateInstallOwner(candidate) === params.pluginId) return true;
const candidatePath = candidate.packageDir ?? candidate.rootDir;
const resolved = resolveUserPath(candidatePath, process.env);
const pathKey = safeRealpathSync(resolved, realpathCache) ?? path.resolve(resolved);
return targetPathKeys.has(pathKey);
});
if (installedCandidates.some(isPluginCandidateInstallOwnerAmbiguous)) throw new Error(`Plugin package "${params.pluginId}" has ambiguous install ownership. Refresh the plugin registry or reinstall the package before retrying.`);
const installedRegistry = loadPluginManifestRegistryCore({
config: reconciledConfig,
candidates: installedCandidates,
diagnostics: installedDiscovery.diagnostics,
installRecords: nextInstallRecords
});
if (installedRegistry.plugins.some(isPluginManifestInstallOwnerAmbiguous)) throw new Error(`Plugin package "${params.pluginId}" has ambiguous install ownership. Refresh the plugin registry or reinstall the package before retrying.`);
const manifests = installedRegistry.plugins.filter((plugin) => resolvePluginManifestInstallOwner(plugin) === params.pluginId);
if (manifests.length === 0) throw new Error(`Plugin package "${params.pluginId}" has no authoritative runtime child list. Refresh the plugin registry, then reinstall the package or run openclaw doctor before retrying.`);
const ownedPluginIds = manifests.map((plugin) => plugin.id).toSorted();
const manifestByPluginId = new Map(manifests.map((plugin) => [plugin.id, plugin]));
const enablementByPluginId = new Map(ownedPluginIds.map((pluginId) => [pluginId, resolvePluginConfigEnablement({
config: reconciledConfig,
pluginId,
manifest: manifestByPluginId.get(pluginId)
})]));
for (const [pluginId, configEnablement] of enablementByPluginId) if (configEnablement.mode === "invalid") throw new Error(`Plugin "${pluginId}" has invalid configured settings: ${configEnablement.error}. Fix plugins.entries.${pluginId}.config, then rerun the install.`);
let next = reconciledConfig;
const enabledPluginIds = [];
for (const pluginId of ownedPluginIds) {
const configEnablement = enablementByPluginId.get(pluginId) ?? { mode: "ready" };
const explicitlyDisabled = reconciledConfig.plugins?.entries?.[pluginId]?.enabled === false;
if (configEnablement.mode === "missing") next = prepareConfigForDisabledInstall(next, pluginId);
if (params.enable === false) continue;
next = removeInstalledPluginFromDenylist(addInstalledPluginToAllowlist(next, pluginId), pluginId);
if (configEnablement.mode !== "ready" || explicitlyDisabled) continue;
const enabled = enablePluginInConfig(next, pluginId, { updateChannelConfig: false });
next = enabled.config;
if (enabled.enabled) enabledPluginIds.push(pluginId);
}
const slotWarnings = [];
const slotMetadata = enabledPluginIds.length ? loadPluginMetadataSnapshot({
allowCurrent: false,
config: next,
index: loadInstalledPluginIndex({
config: next,
candidates: installedCandidates,
diagnostics: installedDiscovery.diagnostics,
installRecords: nextInstallRecords
})
}) : void 0;
for (const pluginId of enabledPluginIds) {
const slotResult = await tracePluginLifecyclePhaseAsync("slot selection", async () => {
params.beforePersistentApply?.();
return applySlotSelectionForPlugin(next, pluginId, slotMetadata);
}, {
command: "install",
pluginId
});
next = slotResult.config;
slotWarnings.push(...slotResult.warnings);
}
next = withoutPluginInstallRecords(next);
await tracePluginLifecyclePhaseAsync("config mutation", () => commitPluginInstallRecordsWithConfig({
previousInstallRecords: installRecords,
nextInstallRecords,
nextConfig: next,
baseHash: params.snapshot.baseHash,
beforePersistentEffect: params.beforePersistentEffect,
writeOptions: {
...params.snapshot.writeOptions,
afterWrite: {
mode: "restart",
reason: "plugin source changed"
},
...params.beforePersistentApply ? { assertConfigPathForWrite: () => {
params.snapshot.writeOptions.assertConfigPathForWrite?.();
params.beforePersistentApply?.();
} } : {}
}
}), { command: "install" });
params.onCommitted?.();
if (replacedInstallRemoval) {
const removalResult = await tracePluginLifecyclePhaseAsync("replaced install cleanup", () => applyPluginUninstallDirectoryRemoval(replacedInstallRemoval), {
command: "install",
pluginId: params.pluginId
});
for (const warning of removalResult.warnings) warn(warning, "A previous plugin installation could not be fully cleaned up. Run `openclaw plugins doctor`.");
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) => warn(message, "Plugin registry refresh or runtime cache invalidation failed. Restart the gateway.") }
});
for (const warning of slotWarnings) warn(warning, warning);
const configurationRequiredPluginIds = [...enablementByPluginId].filter(([, state]) => state.mode === "missing").map(([pluginId]) => pluginId);
const configWarning = params.enable !== false && configurationRequiredPluginIds.length > 0 ? configurationRequiredPluginIds.length === 1 ? `Installed plugin "${configurationRequiredPluginIds[0]}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${configurationRequiredPluginIds[0]}\`.` : `Installed plugin entries ${configurationRequiredPluginIds.join(", ")} without enabling them because they require configuration first. Configure each entry, then run \`openclaw plugins enable <plugin-id>\`.` : void 0;
const warningMessage = [params.warningMessage, configWarning].filter(Boolean).join("\n");
if (warningMessage) warn(warningMessage, configWarning ?? "Plugin installation reported a warning. Run `openclaw plugins doctor`.");
runtime.log(params.successMessage ?? (ownedPluginIds.length > 1 ? `Installed plugin package ${params.pluginId}: ${ownedPluginIds.join(", ")}` : `Installed plugin: ${params.pluginId}`));
logShadowedNpmInstallWarning({
config: next,
pluginId: params.pluginId,
install: params.install,
warn
});
runtime.log("Restart the gateway to load plugins.");
return next;
});
} finally {
clearLoadInstalledPluginIndexInstallRecordsCache();
}
}
//#endregion
export { supportsInstallConfigSingleTopLevelIncludeShape as a, selectInstallMutationWriteOptions as i, resolveCombinedPluginAndHookConfigMutationPreflight as n, resolveInstallConfigMutationPreflights as r, persistPluginInstall as t };