fix(plugins): preserve native loader errors

This commit is contained in:
Vincent Koc
2026-05-03 23:06:14 -07:00
parent 3c4f67141d
commit 0907c60dd7
3 changed files with 118 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Gateway/performance: lazy-load early runtime discovery and shutdown-hook helpers, defer maintenance timers until after readiness, and trim duplicate plugin auto-enable work during Gateway startup.
- Gateway/performance: defer non-readiness sidecars until after the ready signal, avoid hot-path channel plugin barrel imports, and fast-path trusted bundled plugin metadata during Gateway startup.
- Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed.
- Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc.
- QA/Mantis: add a `pnpm openclaw qa mantis discord-smoke` runner and manual GitHub workflow that verify the Mantis Discord bot can see the configured guild/channel, post a smoke message, add a reaction, and upload artifacts.
- QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts.
- QA/Mantis: pass the runtime env through desktop-browser Crabbox and artifact-copy child commands, so embedded Mantis callers can provide Crabbox credentials without mutating the parent process. Thanks @vincentkoc.

View File

@@ -0,0 +1,89 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
isJavaScriptModulePath,
tryNativeRequireJavaScriptModule,
} from "./native-module-require.js";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-native-require-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("tryNativeRequireJavaScriptModule", () => {
it("loads native CommonJS modules", () => {
const dir = makeTempDir();
const modulePath = path.join(dir, "plugin.cjs");
fs.writeFileSync(modulePath, 'module.exports = { marker: "native" };\n', "utf8");
const result = tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true });
expect(result).toEqual({ ok: true, moduleExport: { marker: "native" } });
});
it("declines modules that need source-transform fallback", () => {
const dir = makeTempDir();
const modulePath = path.join(dir, "plugin.mjs");
fs.writeFileSync(
modulePath,
'await Promise.resolve();\nexport const marker = "esm";\n',
"utf8",
);
expect(tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true })).toEqual({
ok: false,
});
});
it("declines missing target modules so callers can try source fallback", () => {
const modulePath = path.join(makeTempDir(), "missing.cjs");
expect(tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true })).toEqual({
ok: false,
});
});
it("propagates missing dependency errors from existing modules", () => {
const dir = makeTempDir();
const modulePath = path.join(dir, "plugin.cjs");
fs.writeFileSync(modulePath, 'require("./missing-dependency.cjs");\n', "utf8");
expect(() => tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true })).toThrow(
"missing-dependency.cjs",
);
});
it("propagates real module evaluation errors instead of falling back", () => {
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 })).toThrow(
"plugin exploded during native load",
);
});
});
describe("isJavaScriptModulePath", () => {
it("only accepts JavaScript runtime extensions", () => {
expect(isJavaScriptModulePath("/plugin/index.js")).toBe(true);
expect(isJavaScriptModulePath("/plugin/index.mjs")).toBe(true);
expect(isJavaScriptModulePath("/plugin/index.cjs")).toBe(true);
expect(isJavaScriptModulePath("/plugin/index.ts")).toBe(false);
});
});

View File

@@ -7,6 +7,30 @@ export function isJavaScriptModulePath(modulePath: string): boolean {
return [".js", ".mjs", ".cjs"].includes(path.extname(modulePath).toLowerCase());
}
function isMissingTargetModuleError(
error: { code?: unknown; message?: unknown },
modulePath: string,
): boolean {
if (error.code !== "MODULE_NOT_FOUND" || typeof error.message !== "string") {
return false;
}
const firstLine = error.message.split("\n", 1)[0] ?? "";
return firstLine.includes(`'${modulePath}'`) || firstLine.includes(`"${modulePath}"`);
}
function isSourceTransformFallbackError(error: unknown, modulePath: string): boolean {
if (!error || typeof error !== "object") {
return false;
}
const candidate = error as { code?: unknown; message?: unknown };
const code = candidate.code;
return (
code === "ERR_REQUIRE_ESM" ||
code === "ERR_REQUIRE_ASYNC_MODULE" ||
isMissingTargetModuleError(candidate, modulePath)
);
}
export function tryNativeRequireJavaScriptModule(
modulePath: string,
options: { allowWindows?: boolean } = {},
@@ -19,7 +43,10 @@ export function tryNativeRequireJavaScriptModule(
}
try {
return { ok: true, moduleExport: nodeRequire(modulePath) };
} catch {
} catch (error) {
if (!isSourceTransformFallbackError(error, modulePath)) {
throw error;
}
return { ok: false };
}
}