mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
fix(plugins): harden setup runtime loading
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user