fix(plugins): harden setup runtime loading

This commit is contained in:
Gustavo Madeira Santana
2026-04-15 10:18:18 -04:00
parent f02b9f2cf1
commit c9c212dbeb
4 changed files with 255 additions and 55 deletions

View File

@@ -70,6 +70,27 @@ describe("createPluginRuntimeStore", () => {
expect(secondStore.getRuntime()).toEqual({ value: "custom" });
});
test("rejects empty plugin ids", () => {
expect(() =>
createPluginRuntimeStore({
pluginId: " ",
errorMessage: "runtime not initialized",
}),
).toThrow("pluginId must not be empty");
});
test("treats falsy runtime values as initialized", () => {
const store = createPluginRuntimeStore<number>({
key: "custom-falsy-runtime-key",
errorMessage: "runtime not initialized",
});
store.clearRuntime();
store.setRuntime(0);
expect(store.getRuntime()).toBe(0);
});
test("shares runtime slots across duplicate module instances when plugin id matches", async () => {
const firstModule = await importFreshModule<typeof import("./runtime-store.js")>(
import.meta.url,

View File

@@ -22,7 +22,11 @@ function getPluginRuntimeStoreRegistry(): PluginRuntimeStoreRegistry {
}
function pluginRuntimeStoreKeyForPluginId(pluginId: string): string {
return `plugin-runtime:${pluginId.trim()}`;
const normalizedPluginId = pluginId.trim();
if (!normalizedPluginId) {
throw new Error("createPluginRuntimeStore: pluginId must not be empty");
}
return `plugin-runtime:${normalizedPluginId}`;
}
function resolvePluginRuntimeStoreOptions(
@@ -78,7 +82,7 @@ export function createPluginRuntimeStore<T>(options: string | PluginRuntimeStore
return (slot.runtime as T | null) ?? null;
},
getRuntime() {
if (!slot.runtime) {
if (slot.runtime === null) {
throw new Error(resolved.errorMessage);
}
return slot.runtime as T;

View File

@@ -526,7 +526,9 @@ function createSetupEntryChannelPluginFixture(params: {
useBundledSetupEntryContract?: boolean;
splitBundledSetupSecrets?: boolean;
bundledSetupRuntimeMarker?: string;
bundledSetupRuntimeError?: string;
bundledFullRuntimeMarker?: string;
requireBundledFullRuntimeBeforeLoad?: boolean;
}) {
useNoBundledPlugins();
const pluginDir = makeTempDir();
@@ -581,22 +583,31 @@ module.exports = {
id: ${JSON.stringify(params.id)},
name: ${JSON.stringify(params.label)},
description: ${JSON.stringify(params.fullBlurb)},
loadChannelPlugin: () => ({
id: ${JSON.stringify(params.id)},
meta: {
loadChannelPlugin: () => {
${
params.requireBundledFullRuntimeBeforeLoad && params.bundledFullRuntimeMarker
? `if (!require("node:fs").existsSync(${JSON.stringify(params.bundledFullRuntimeMarker)})) {
throw new Error("bundled runtime not initialized");
}`
: ""
}
return {
id: ${JSON.stringify(params.id)},
label: ${JSON.stringify(params.label)},
selectionLabel: ${JSON.stringify(params.label)},
docsPath: ${JSON.stringify(`/channels/${params.id}`)},
blurb: ${JSON.stringify(params.fullBlurb)},
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ${listAccountIds},
resolveAccount: () => ${resolveAccount},
},
outbound: { deliveryMode: "direct" },
}),
meta: {
id: ${JSON.stringify(params.id)},
label: ${JSON.stringify(params.label)},
selectionLabel: ${JSON.stringify(params.label)},
docsPath: ${JSON.stringify(`/channels/${params.id}`)},
blurb: ${JSON.stringify(params.fullBlurb)},
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ${listAccountIds},
resolveAccount: () => ${resolveAccount},
},
outbound: { deliveryMode: "direct" },
};
},
${
params.bundledFullRuntimeMarker
? `setChannelRuntime: () => {
@@ -667,11 +678,15 @@ module.exports = {
: ""
}
${
params.bundledSetupRuntimeMarker
params.bundledSetupRuntimeError
? `setChannelRuntime: () => {
throw new Error(${JSON.stringify(params.bundledSetupRuntimeError)});
},`
: params.bundledSetupRuntimeMarker
? `setChannelRuntime: () => {
require("node:fs").writeFileSync(${JSON.stringify(params.bundledSetupRuntimeMarker)}, "loaded", "utf-8");
},`
: ""
: ""
}
};`
: `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
@@ -3265,6 +3280,36 @@ module.exports = {
expectSetupLoaded: true,
expectedChannels: 0,
},
{
name: "keeps bundled setupEntry setup-only loads on the setup-safe path",
fixture: {
id: "setup-only-bundled-contract-test",
label: "Setup Only Bundled Contract Test",
packageName: "@openclaw/setup-only-bundled-contract-test",
fullBlurb: "full entry should not run in setup-only mode",
setupBlurb: "setup-only bundled contract",
configured: false,
useBundledSetupEntryContract: true,
},
load: ({ pluginDir }: { pluginDir: string }) =>
loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [pluginDir] },
allow: ["setup-only-bundled-contract-test"],
entries: {
"setup-only-bundled-contract-test": { enabled: false },
},
},
},
includeSetupOnlyChannelPlugins: true,
onlyPluginIds: ["setup-only-bundled-contract-test"],
}),
expectFullLoaded: false,
expectSetupLoaded: true,
expectedChannels: 0,
},
{
name: "uses package setupEntry for enabled but unconfigured channel loads",
fixture: {
@@ -3475,6 +3520,75 @@ module.exports = {
},
);
it("applies the bundled runtime setter before loading the merged setup-runtime plugin", () => {
const runtimeMarker = path.join(makeTempDir(), "setup-runtime-before-load.txt");
const built = createSetupEntryChannelPluginFixture({
id: "setup-runtime-order-test",
label: "Setup Runtime Order Test",
packageName: "@openclaw/setup-runtime-order-test",
fullBlurb: "full runtime plugin",
setupBlurb: "setup runtime override",
configured: false,
useBundledFullEntryContract: true,
useBundledSetupEntryContract: true,
bundledFullRuntimeMarker: runtimeMarker,
requireBundledFullRuntimeBeforeLoad: true,
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [built.pluginDir] },
allow: ["setup-runtime-order-test"],
},
},
});
expect(registry.plugins.find((entry) => entry.id === "setup-runtime-order-test")?.status).toBe(
"loaded",
);
expect(fs.existsSync(runtimeMarker)).toBe(true);
});
it("records setup runtime setter failures without aborting the full load pass", () => {
const built = createSetupEntryChannelPluginFixture({
id: "setup-runtime-error-test",
label: "Setup Runtime Error Test",
packageName: "@openclaw/setup-runtime-error-test",
fullBlurb: "full runtime plugin",
setupBlurb: "setup runtime override",
configured: false,
useBundledSetupEntryContract: true,
bundledSetupRuntimeError: "broken setup runtime setter",
});
const helperPlugin = writePlugin({
id: "setup-runtime-helper-test",
filename: "setup-runtime-helper-test.cjs",
body: `module.exports = { id: "setup-runtime-helper-test", register() {} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [built.pluginDir, helperPlugin.file] },
allow: ["setup-runtime-error-test", "setup-runtime-helper-test"],
},
},
});
expect(registry.plugins.find((entry) => entry.id === "setup-runtime-error-test")?.status).toBe(
"error",
);
expect(
registry.plugins.find((entry) => entry.id === "setup-runtime-error-test")?.error,
).toContain("broken setup runtime setter");
expect(registry.plugins.find((entry) => entry.id === "setup-runtime-helper-test")?.status).toBe(
"loaded",
);
});
it("isolates loadSetupPlugin errors as per-plugin diagnostics instead of crashing registry load", () => {
useNoBundledPlugins();
const pluginDir = makeTempDir();

View File

@@ -684,9 +684,9 @@ function mergeSetupRuntimeChannelPlugin(
}
function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): {
plugin?: ChannelPlugin;
loadChannelPlugin?: () => ChannelPlugin;
loadChannelSecrets?: () => ChannelPlugin["secrets"] | undefined;
setChannelRuntime?: (runtime: PluginRuntime) => void;
loadError?: unknown;
} {
const resolved = unwrapDefaultModuleExport(moduleExport);
if (!resolved || typeof resolved !== "object") {
@@ -704,33 +704,48 @@ function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): {
) {
return {};
}
return {
loadChannelPlugin: entryRecord.loadChannelPlugin as () => ChannelPlugin,
...(typeof entryRecord.loadChannelSecrets === "function"
? {
loadChannelSecrets: entryRecord.loadChannelSecrets as () =>
| ChannelPlugin["secrets"]
| undefined,
}
: {}),
...(typeof entryRecord.setChannelRuntime === "function"
? {
setChannelRuntime: entryRecord.setChannelRuntime as (runtime: PluginRuntime) => void,
}
: {}),
};
}
function loadBundledRuntimeChannelPlugin(params: {
registration: ReturnType<typeof resolveBundledRuntimeChannelRegistration>;
}): {
plugin?: ChannelPlugin;
loadError?: unknown;
} {
if (typeof params.registration.loadChannelPlugin !== "function") {
return {};
}
try {
const loadedPlugin = entryRecord.loadChannelPlugin();
const loadedSecrets =
typeof entryRecord.loadChannelSecrets === "function"
? (entryRecord.loadChannelSecrets() as ChannelPlugin["secrets"] | undefined)
: undefined;
if (loadedPlugin && typeof loadedPlugin === "object") {
const mergedSecrets = mergeChannelPluginSection(
(loadedPlugin as ChannelPlugin).secrets,
loadedSecrets,
);
return {
plugin: {
...(loadedPlugin as ChannelPlugin),
...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}),
},
...(typeof entryRecord.setChannelRuntime === "function"
? {
setChannelRuntime: entryRecord.setChannelRuntime as (runtime: PluginRuntime) => void,
}
: {}),
};
const loadedPlugin = params.registration.loadChannelPlugin();
const loadedSecrets = params.registration.loadChannelSecrets?.();
if (!loadedPlugin || typeof loadedPlugin !== "object") {
return {};
}
const mergedSecrets = mergeChannelPluginSection(loadedPlugin.secrets, loadedSecrets);
return {
plugin: {
...loadedPlugin,
...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}),
},
};
} catch (err) {
return { loadError: err };
}
return {};
}
function resolveSetupChannelRegistration(moduleExport: unknown): {
@@ -1783,8 +1798,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (setupRegistration.plugin) {
const api = createApi(record, {
config: cfg,
pluginConfig: {},
hookPolicy: entry?.hooks,
registrationMode,
});
let mergedSetupRegistration = setupRegistration;
if (setupRegistration.usesBundledSetupContract && candidate.source !== safeSource) {
let runtimeSetterApplied = false;
if (
registrationMode === "setup-runtime" &&
setupRegistration.usesBundledSetupContract &&
candidate.source !== safeSource
) {
const runtimeOpened = openBoundaryFileSync({
absolutePath: candidate.source,
rootPath: pluginRoot,
@@ -1824,7 +1850,30 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
const runtimeRegistration = resolveBundledRuntimeChannelRegistration(runtimeMod);
if (runtimeRegistration.loadError) {
if (runtimeRegistration.setChannelRuntime) {
try {
runtimeRegistration.setChannelRuntime(api.runtime);
runtimeSetterApplied = true;
} catch (err) {
recordPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
phase: "load",
error: err,
logPrefix: `[plugins] ${record.id} failed to apply setup-runtime channel runtime from ${record.source}: `,
diagnosticMessagePrefix: "failed to apply setup-runtime channel runtime: ",
});
continue;
}
}
const runtimePluginRegistration = loadBundledRuntimeChannelPlugin({
registration: runtimeRegistration,
});
if (runtimePluginRegistration.loadError) {
recordPluginError({
logger,
registry,
@@ -1833,17 +1882,17 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
pluginId,
origin: candidate.origin,
phase: "load",
error: runtimeRegistration.loadError,
error: runtimePluginRegistration.loadError,
logPrefix: `[plugins] ${record.id} failed to load setup-runtime channel entry from ${record.source}: `,
diagnosticMessagePrefix: "failed to load setup-runtime channel entry: ",
});
continue;
}
if (runtimeRegistration.plugin) {
if (runtimePluginRegistration.plugin) {
mergedSetupRegistration = {
...setupRegistration,
plugin: mergeSetupRuntimeChannelPlugin(
runtimeRegistration.plugin,
runtimePluginRegistration.plugin,
setupRegistration.plugin,
),
setChannelRuntime:
@@ -1861,13 +1910,25 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
);
continue;
}
const api = createApi(record, {
config: cfg,
pluginConfig: {},
hookPolicy: entry?.hooks,
registrationMode,
});
mergedSetupRegistration.setChannelRuntime?.(api.runtime);
if (!runtimeSetterApplied) {
try {
mergedSetupRegistration.setChannelRuntime?.(api.runtime);
} catch (err) {
recordPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
phase: "load",
error: err,
logPrefix: `[plugins] ${record.id} failed to apply setup channel runtime from ${record.source}: `,
diagnosticMessagePrefix: "failed to apply setup channel runtime: ",
});
continue;
}
}
api.registerChannel(mergedSetupPlugin);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);