diff --git a/CHANGELOG.md b/CHANGELOG.md index 51594fb51a3..08695002af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/plugins/native-module-require.test.ts b/src/plugins/native-module-require.test.ts new file mode 100644 index 00000000000..6566a930aa0 --- /dev/null +++ b/src/plugins/native-module-require.test.ts @@ -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); + }); +}); diff --git a/src/plugins/native-module-require.ts b/src/plugins/native-module-require.ts index ef642afcb07..a89d7beb723 100644 --- a/src/plugins/native-module-require.ts +++ b/src/plugins/native-module-require.ts @@ -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 }; } }