fix: preserve source plugin loading fallbacks

This commit is contained in:
Peter Steinberger
2026-05-06 01:40:58 +01:00
parent 05eda57b3c
commit e3b0707a53
11 changed files with 170 additions and 65 deletions

View File

@@ -133,7 +133,7 @@ const knownDeprecatedSurfaceMarkers = [
{
code: "legacy-root-sdk-import",
file: "src/plugin-sdk/compat.ts",
marker: "@deprecated Use `openclaw/plugin-sdk/channel-reply-pipeline`.",
marker: "@deprecated Use `openclaw/plugin-sdk/channel-message`.",
},
{
code: "channel-route-key-aliases",

View File

@@ -47,7 +47,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";',
'export { createAccountStatusSink, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";',
'export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";',
'export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";',
'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";',
'export { evaluateGroupRouteAccessForPolicy, resolveDmGroupAccessWithLists, resolveSenderScopedGroupPolicy } from "openclaw/plugin-sdk/channel-policy";',
'export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";',
'export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";',
@@ -77,7 +77,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { logTypingFailure } from "openclaw/plugin-sdk/channel-logging";',
'export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";',
'export { evaluateSenderGroupAccessForPolicy, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, resolveSenderScopedGroupPolicy, resolveToolsBySender } from "openclaw/plugin-sdk/channel-policy";',
'export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";',
'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";',
'export { PAIRING_APPROVED_MESSAGE, buildProbeChannelStatusSummary, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/channel-status";',
'export { buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision } from "openclaw/plugin-sdk/channel-targets";',
'export type { GroupPolicy, GroupToolPolicyConfig, MSTeamsChannelConfig, MSTeamsConfig, MSTeamsReplyStyle, MSTeamsTeamConfig, MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";',
@@ -133,7 +133,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithCommandGate } from "openclaw/plugin-sdk/channel-policy";',
'export type { BlockStreamingCoalesceConfig, DmConfig, DmPolicy, GroupPolicy, GroupToolPolicyConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types";',
'export { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";',
'export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";',
'export { dispatchChannelMessageReplyWithBase } from "openclaw/plugin-sdk/channel-message";',
'export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";',
'export { deliverFormattedTextWithAttachments } from "openclaw/plugin-sdk/reply-payload";',
'export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";',

View File

@@ -1038,7 +1038,10 @@ describe("loadOpenClawPlugins", () => {
});
const record = registry.plugins.find((entry) => entry.id === "discord");
expect(record?.status, record?.error).toBe("loaded");
expect(
record?.status,
JSON.stringify({ error: record?.error, diagnostics: registry.diagnostics }, null, 2),
).toBe("loaded");
});
it("registers standalone text transforms", () => {
useNoBundledPlugins();
@@ -6592,7 +6595,10 @@ module.exports = {
}),
);
const record = registry.plugins.find((entry) => entry.id === "legacy-root-import");
expect(record?.status, record?.error).toBe("loaded");
expect(
record?.status,
JSON.stringify({ error: record?.error, diagnostics: registry.diagnostics }, null, 2),
).toBe("loaded");
});
it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => {
@@ -6640,7 +6646,10 @@ module.exports = {
const record = registry.plugins.find(
(entry) => entry.id === "legacy-root-diagnostic-listener",
);
expect(record?.status, record?.error).toBe("loaded");
expect(
record?.status,
JSON.stringify({ error: record?.error, diagnostics: registry.diagnostics }, null, 2),
).toBe("loaded");
emitDiagnosticEvent({
type: "model.usage",

View File

@@ -77,6 +77,20 @@ describe("tryNativeRequireJavaScriptModule", () => {
).toEqual({ ok: false });
});
it("declines missing dependency errors when the caller can use source transform fallback", () => {
const dir = makeTempDir();
const modulePath = path.join(dir, "plugin.cjs");
fs.writeFileSync(modulePath, 'require("./helper.js");\n', "utf8");
fs.writeFileSync(path.join(dir, "helper.ts"), "export const loaded = true;\n", "utf8");
expect(
tryNativeRequireJavaScriptModule(modulePath, {
allowWindows: true,
fallbackOnNativeError: true,
}),
).toEqual({ ok: false });
});
it("propagates real module evaluation errors instead of falling back", () => {
const dir = makeTempDir();
const modulePath = path.join(dir, "plugin.cjs");
@@ -90,6 +104,23 @@ describe("tryNativeRequireJavaScriptModule", () => {
"plugin exploded during native load",
);
});
it("declines real module evaluation errors when the caller can use source transform fallback", () => {
const dir = makeTempDir();
const modulePath = path.join(dir, "plugin.cjs");
fs.writeFileSync(
modulePath,
'throw new Error("plugin exploded during native load");\n',
"utf8",
);
expect(
tryNativeRequireJavaScriptModule(modulePath, {
allowWindows: true,
fallbackOnNativeError: true,
}),
).toEqual({ ok: false });
});
});
describe("isJavaScriptModulePath", () => {

View File

@@ -1,7 +1,17 @@
import { createRequire } from "node:module";
import Module from "node:module";
import path from "node:path";
const nodeRequire = createRequire(import.meta.url);
type ResolveFilename = (
request: string,
parent: NodeJS.Module | undefined,
isMain: boolean,
options?: { paths?: string[] },
) => string;
const moduleWithResolver = Module as typeof Module & {
_resolveFilename?: ResolveFilename;
};
export function isJavaScriptModulePath(modulePath: string): boolean {
return [".js", ".mjs", ".cjs"].includes(path.extname(modulePath).toLowerCase());
@@ -33,7 +43,12 @@ function isSourceTransformFallbackError(error: unknown, modulePath: string): boo
export function tryNativeRequireJavaScriptModule(
modulePath: string,
options: { allowWindows?: boolean; fallbackOnMissingDependency?: boolean } = {},
options: {
allowWindows?: boolean;
aliasMap?: Record<string, string>;
fallbackOnMissingDependency?: boolean;
fallbackOnNativeError?: boolean;
} = {},
): { ok: true; moduleExport: unknown } | { ok: false } {
if (process.platform === "win32" && options.allowWindows !== true) {
return { ok: false };
@@ -42,19 +57,47 @@ export function tryNativeRequireJavaScriptModule(
return { ok: false };
}
try {
return { ok: true, moduleExport: nodeRequire(modulePath) };
return { ok: true, moduleExport: requireWithOptionalAliases(modulePath, options.aliasMap) };
} catch (error) {
const code =
error && typeof error === "object" ? (error as { code?: unknown }).code : undefined;
if (
!isSourceTransformFallbackError(error, modulePath) &&
!(
options.fallbackOnMissingDependency === true &&
(code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND")
)
isSourceTransformFallbackError(error, modulePath) ||
options.fallbackOnNativeError ||
(options.fallbackOnMissingDependency === true &&
(code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND"))
) {
throw error;
return { ok: false };
}
return { ok: false };
throw error;
}
}
function requireWithOptionalAliases(
modulePath: string,
aliasMap: Record<string, string> | undefined,
): unknown {
return withNativeRequireAliases(aliasMap, () => nodeRequire(modulePath));
}
export function withNativeRequireAliases<T>(
aliasMap: Record<string, string> | undefined,
run: () => T,
): T {
if (!aliasMap || Object.keys(aliasMap).length === 0 || !moduleWithResolver._resolveFilename) {
return run();
}
const originalResolveFilename = moduleWithResolver._resolveFilename;
moduleWithResolver._resolveFilename = ((request, parent, isMain, options) => {
const aliasTarget = aliasMap[request];
if (aliasTarget) {
return aliasTarget;
}
return originalResolveFilename(request, parent, isMain, options);
}) satisfies ResolveFilename;
try {
return run();
} finally {
moduleWithResolver._resolveFilename = originalResolveFilename;
}
}

View File

@@ -15,21 +15,19 @@ async function loadCachedPluginModuleLoader(scope: string) {
options,
}),
);
vi.doMock("jiti", () => ({
createJiti,
}));
const { getCachedPluginModuleLoader } = await importFreshModule<
const pluginModuleLoaderCache = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, `./plugin-module-loader-cache.js?scope=${scope}`);
const getCachedPluginModuleLoaderWithMock: typeof getCachedPluginModuleLoader = (params) =>
getCachedPluginModuleLoader({
const getCachedPluginModuleLoader: typeof pluginModuleLoaderCache.getCachedPluginModuleLoader = (
params,
) =>
pluginModuleLoaderCache.getCachedPluginModuleLoader({
...params,
createLoader: params.createLoader ?? asPluginModuleLoaderFactory(createJiti),
});
return { createJiti, getCachedPluginModuleLoader: getCachedPluginModuleLoaderWithMock };
return { createJiti, getCachedPluginModuleLoader };
}
function asPluginModuleLoaderFactory(factory: unknown): PluginModuleLoaderFactory {
@@ -372,8 +370,6 @@ describe("getCachedPluginModuleLoader", () => {
it("serves compiled .js targets from native require without invoking the module loader", async () => {
const fromSourceTransformer = vi.fn();
const createJiti = vi.fn(() => fromSourceTransformer);
const jitiModuleFactory = vi.fn(() => ({ createJiti }));
vi.doMock("jiti", jitiModuleFactory);
const nativeStub = vi.fn((target: string) => ({
ok: true as const,
moduleExport: { loadedFrom: target },
@@ -400,13 +396,17 @@ describe("getCachedPluginModuleLoader", () => {
expect(result.loadedFrom).toBe("/repo/dist/extensions/demo/api.js");
// Jiti should not be constructed or invoked for .js targets that
// `tryNativeRequireJavaScriptModule` resolves.
expect(jitiModuleFactory).not.toHaveBeenCalled();
expect(createJiti).not.toHaveBeenCalled();
expect(fromSourceTransformer).not.toHaveBeenCalled();
// allowWindows must be passed so the native fast path works on Windows too.
expect(nativeStub).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", {
allowWindows: true,
});
expect(nativeStub).toHaveBeenCalledWith(
"/repo/dist/extensions/demo/api.js",
expect.objectContaining({
allowWindows: true,
fallbackOnMissingDependency: true,
fallbackOnNativeError: true,
}),
);
expect(getPluginModuleLoaderStats()).toMatchObject({
calls: 1,
nativeHits: 1,
@@ -446,9 +446,14 @@ describe("getCachedPluginModuleLoader", () => {
expect(() => loader("/repo/dist/extensions/demo/api.js")).toThrow("missing-dep");
expect(createJiti).not.toHaveBeenCalled();
expect(fromSourceTransformer).not.toHaveBeenCalled();
expect(nativeStub).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", {
allowWindows: true,
});
expect(nativeStub).toHaveBeenCalledWith(
"/repo/dist/extensions/demo/api.js",
expect.objectContaining({
allowWindows: true,
fallbackOnMissingDependency: true,
fallbackOnNativeError: true,
}),
);
expect(getPluginModuleLoaderStats()).toMatchObject({
calls: 1,
nativeHits: 0,
@@ -461,7 +466,6 @@ describe("getCachedPluginModuleLoader", () => {
it("falls back to source transform when the native-require helper declines", async () => {
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: () => ({ ok: false }),
@@ -481,6 +485,10 @@ describe("getCachedPluginModuleLoader", () => {
const result = loader("/repo/dist/extensions/demo/api.js") as { fromSourceTransform: boolean };
expect(result.fromSourceTransform).toBe(true);
expect(createJiti).toHaveBeenCalledWith(
"file:///repo/src/plugins/public-surface-loader.ts",
expect.objectContaining({ tryNative: true }),
);
expect(fromSourceTransformer).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js");
expect(getPluginModuleLoaderStats()).toMatchObject({
calls: 1,
@@ -496,7 +504,6 @@ describe("getCachedPluginModuleLoader", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: () => ({ ok: false }),
@@ -529,7 +536,6 @@ describe("getCachedPluginModuleLoader", () => {
it("skips the native-require fast path when tryNative is explicitly false", async () => {
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
const nativeStub = vi.fn(() => ({ ok: true, moduleExport: { fromNative: true } }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
@@ -570,7 +576,6 @@ describe("getCachedPluginModuleLoader", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
const nativeStub = vi.fn(() => ({ ok: true, moduleExport: { fromNative: true } }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
@@ -605,7 +610,6 @@ describe("getCachedPluginModuleLoader", () => {
it("forwards extra loader arguments through to the source-transform fallback", async () => {
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: () => ({ ok: false }),

View File

@@ -1,5 +1,7 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { createJiti } from "jiti";
import { toSafeImportPath } from "../shared/import-specifier.js";
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
@@ -129,6 +131,13 @@ export function createPluginModuleLoaderCache(
return new PluginLruCache<PluginModuleLoader>(maxEntries);
}
function toSourceTransformImportPath(specifier: string): string {
if (process.platform === "win32" && path.isAbsolute(specifier)) {
return pathToFileURL(specifier).href;
}
return toSafeImportPath(specifier);
}
function resolveDefaultPluginModuleLoaderConfig(
params: ResolvePluginModuleLoaderCacheEntryParams,
): ReturnType<typeof resolvePluginLoaderModuleConfig> {
@@ -185,7 +194,7 @@ export function resolvePluginModuleLoaderCacheEntry(
function createLazySourceTransformLoader(params: {
loaderFilename: string;
aliasMap: Record<string, string>;
tryNative: boolean;
sourceTransformTryNative: boolean;
createLoader?: PluginModuleLoaderFactory;
}): () => PluginModuleLoader {
let loadWithSourceTransform: PluginModuleLoader | undefined;
@@ -197,7 +206,7 @@ function createLazySourceTransformLoader(params: {
params.loaderFilename,
{
...buildPluginLoaderJitiOptions(params.aliasMap),
tryNative: params.tryNative,
tryNative: params.sourceTransformTryNative,
},
);
loadWithSourceTransform = new Proxy(jitiLoader, {
@@ -205,7 +214,7 @@ function createLazySourceTransformLoader(params: {
const [first, ...rest] = argArray as [unknown, ...unknown[]];
if (typeof first === "string") {
return Reflect.apply(target, thisArg, [
toSafeImportPath(first),
toSourceTransformImportPath(first),
...rest,
] as never) as never;
}
@@ -245,7 +254,10 @@ function createPluginModuleLoader(params: {
tryNative: boolean;
createLoader?: PluginModuleLoaderFactory;
}): PluginModuleLoader {
const getLoadWithSourceTransform = createLazySourceTransformLoader(params);
const getLoadWithSourceTransform = createLazySourceTransformLoader({
...params,
sourceTransformTryNative: params.tryNative,
});
// When the caller has explicitly opted out of native loading (for example
// `bundled-capability-runtime` in Vitest+dist mode, which depends on
// jiti's alias rewriting to surface a narrow SDK slice), route every
@@ -270,7 +282,7 @@ function createPluginModuleLoader(params: {
// handle.
const getLoadWithAliasTransform = createLazySourceTransformLoader({
...params,
tryNative: false,
sourceTransformTryNative: false,
});
return ((target: string, ...rest: unknown[]) => {
pluginModuleLoaderStats.calls += 1;
@@ -284,6 +296,9 @@ function createPluginModuleLoader(params: {
}
const native = tryNativeRequireJavaScriptModule(target, {
allowWindows: true,
aliasMap: params.aliasMap,
fallbackOnMissingDependency: true,
fallbackOnNativeError: true,
});
if (native.ok) {
pluginModuleLoaderStats.nativeHits += 1;

View File

@@ -128,7 +128,10 @@ export function loadPluginBoundaryModule<TModule>(
options: { origin?: PluginOrigin } = {},
): TModule {
if (isJavaScriptModulePath(modulePath)) {
const native = tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true });
const native = tryNativeRequireJavaScriptModule(modulePath, {
allowWindows: true,
fallbackOnNativeError: options.origin !== "bundled",
});
if (native.ok) {
return native.moduleExport as TModule;
}

View File

@@ -73,6 +73,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"pendingFinalDeliveryLastError",
"pendingFinalDeliveryText",
"pendingFinalDeliveryContext",
"pendingFinalDeliveryIntentId",
"totalTokensFresh",
"estimatedCostUsd",
"cacheRead",

View File

@@ -2,7 +2,6 @@ import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { shouldExpectNativeJitiForJavaScriptTestRuntime } from "../test-utils/jiti-runtime.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
import {
getRegistryJitiMocks,
@@ -194,7 +193,6 @@ describe("setup-registry module loader", () => {
});
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const restoreVersions = forceNodeRuntimeVersionsForTest();
const expectedTryNative = shouldExpectNativeJitiForJavaScriptTestRuntime();
try {
resolvePluginSetupRegistry({
@@ -212,7 +210,7 @@ describe("setup-registry module loader", () => {
);
expect(mocks.createJiti.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
tryNative: expectedTryNative,
tryNative: false,
}),
);
});

View File

@@ -1,39 +1,40 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setBundledPluginsDirOverrideForTest } from "./bundled-dir.js";
import { loadOpenClawPlugins } from "./loader.js";
describe("source checkout bundled plugin runtime", () => {
it("loads enabled bundled plugins from built dist or source checkout", () => {
beforeEach(() => {
setBundledPluginsDirOverrideForTest(path.join(process.cwd(), "extensions"));
});
afterEach(() => {
setBundledPluginsDirOverrideForTest(undefined);
});
it("loads enabled bundled plugins from source checkout", () => {
const registry = loadOpenClawPlugins({
cache: false,
onlyPluginIds: ["twitch"],
onlyPluginIds: ["tokenjuice"],
config: {
plugins: {
entries: {
twitch: { enabled: true },
tokenjuice: { enabled: true },
},
},
},
});
const twitch = registry.plugins.find((plugin) => plugin.id === "twitch");
expect(twitch).toMatchObject({
const tokenjuice = registry.plugins.find((plugin) => plugin.id === "tokenjuice");
expect(tokenjuice).toMatchObject({
status: "loaded",
origin: "bundled",
});
const runtimeCandidates = [
`${path.sep}dist${path.sep}extensions${path.sep}twitch${path.sep}index.js`,
`${path.sep}extensions${path.sep}twitch${path.sep}index.ts`,
];
const rootCandidates = [
`${path.sep}dist${path.sep}extensions${path.sep}twitch`,
`${path.sep}extensions${path.sep}twitch`,
];
const includesAny = (actual: string | undefined, candidates: readonly string[]) =>
actual !== undefined && candidates.some((candidate) => actual.includes(candidate));
const expectedRuntime = `${path.sep}extensions${path.sep}tokenjuice${path.sep}index.ts`;
const expectedRoot = `${path.sep}extensions${path.sep}tokenjuice`;
expect(includesAny(twitch?.source, runtimeCandidates)).toBe(true);
expect(includesAny(twitch?.rootDir, rootCandidates)).toBe(true);
expect(tokenjuice?.source).toContain(expectedRuntime);
expect(tokenjuice?.rootDir).toContain(expectedRoot);
});
});