Files
openclaw/src/plugins/bundled-install.ts
Peter Steinberger a12e0f26ee fix(plugins): serialize lifecycle mutations and preserve setup-required installs (#112763)
* fix(plugins): harden concurrent lifecycle mutations

* fix(update): preserve post-core restart snapshot

* fix(update): retain authoritative plugin records on resume

* fix(update): reconcile plugin records after handoff
2026-07-22 23:25:48 -04:00

111 lines
4.0 KiB
TypeScript

import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import type { BundledPluginSource } from "./bundled-sources.js";
import {
persistPluginInstall,
type ConfigSnapshotForInstallPersist,
} from "./install-persistence.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
type BundledPluginConfigEnablement =
| { mode: "ready" }
| { mode: "missing" }
| { mode: "invalid"; error: string };
function resolveBundledPluginConfigEnablement(params: {
bundledSource: BundledPluginSource;
existingEntry: unknown;
}): BundledPluginConfigEnablement {
if (!params.bundledSource.requiresConfig) {
return { mode: "ready" };
}
const entry = isRecord(params.existingEntry) ? params.existingEntry : undefined;
if (!entry || !Object.hasOwn(entry, "config")) {
return { mode: "missing" };
}
const config = entry.config;
if (!params.bundledSource.configSchema) {
return isRecord(config) && Object.keys(config).length > 0
? { mode: "ready" }
: { mode: "invalid", error: "config must be a non-empty object" };
}
const result = validateJsonSchemaValue({
schema: params.bundledSource.configSchema,
cacheKey: `bundled-install:${params.bundledSource.pluginId}`,
value: config,
applyDefaults: true,
});
return result.ok
? { mode: "ready" }
: { mode: "invalid", error: result.errors[0]?.text ?? "invalid plugin config" };
}
function prepareConfigForDisabledBundledInstall(
config: OpenClawConfig,
pluginId: string,
): OpenClawConfig {
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 },
},
},
};
}
export async function installBundledPluginSource(params: {
snapshot: ConfigSnapshotForInstallPersist;
rawSpec: string;
bundledSource: BundledPluginSource;
warning?: string;
invalidateRuntimeCache?: boolean;
runtime?: RuntimeEnv;
}): Promise<{ pluginId: string; warnings: string[] }> {
// Bundled plugins with required config are recorded but not enabled until config validates.
const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId];
const configEnablement = resolveBundledPluginConfigEnablement({
bundledSource: params.bundledSource,
existingEntry,
});
if (configEnablement.mode === "invalid") {
throw new Error(
`Plugin "${params.bundledSource.pluginId}" has invalid configured settings: ${configEnablement.error}. Fix plugins.entries.${params.bundledSource.pluginId}.config, then rerun the install.`,
);
}
const shouldEnable = configEnablement.mode === "ready";
const configBase = shouldEnable
? params.snapshot.config
: prepareConfigForDisabledBundledInstall(params.snapshot.config, params.bundledSource.pluginId);
const configWarning = shouldEnable
? undefined
: `Installed bundled plugin "${params.bundledSource.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.bundledSource.pluginId}\`.`;
const warnings = [params.warning, configWarning].filter((warning): warning is string =>
Boolean(warning),
);
await persistPluginInstall({
snapshot: {
...params.snapshot,
config: configBase,
},
pluginId: params.bundledSource.pluginId,
install: {
source: "path",
spec: params.rawSpec,
sourcePath: params.bundledSource.localPath,
installPath: params.bundledSource.localPath,
},
enable: shouldEnable,
invalidateRuntimeCache: params.invalidateRuntimeCache,
...(warnings.length > 0 ? { warningMessage: warnings.join("\n") } : {}),
runtime: params.runtime,
});
return { pluginId: params.bundledSource.pluginId, warnings };
}