diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index e719107471f..a24cd96f2d7 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -105,6 +105,8 @@ function listSourceBundledPluginRoots(): string[] { } afterEach(() => { + delete (globalThis as { __openclawBundledChannelReenter?: () => void }) + .__openclawBundledChannelReenter; vi.resetModules(); vi.doUnmock("../../plugins/bundled-channel-runtime.js"); vi.doUnmock("../../plugins/bundled-plugin-metadata.js"); @@ -835,21 +837,48 @@ describe("bundled channel entry shape guards", () => { it("breaks reentrant bundled channel discovery cycles with an empty fallback", async () => { const pluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-reentrant-")); - const modulePath = path.join(pluginDir, "index.js"); - fs.writeFileSync(modulePath, "export {};\n", "utf8"); + const modulePath = path.join(pluginDir, "index.cjs"); + fs.writeFileSync( + modulePath, + ` +const reenter = globalThis.__openclawBundledChannelReenter; +if (typeof reenter === "function") { + reenter(); +} +module.exports = { + default: { + kind: "bundled-channel-entry", + id: "alpha", + name: "Alpha", + description: "Alpha", + configSchema: {}, + register() {}, + loadChannelPlugin() { + return { + id: "alpha", + meta: {}, + capabilities: {}, + config: {}, + }; + }, + }, +}; +`, + "utf8", + ); - vi.doMock("../../plugins/bundled-plugin-metadata.js", async (importOriginal) => { + vi.doMock("../../plugins/bundled-channel-runtime.js", async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, - listBundledPluginMetadata: () => [ + listBundledChannelPluginMetadata: () => [ { dirName: "alpha", idHint: "alpha", source: { - source: "./index.js", - built: "./index.js", + source: "./index.cjs", + built: "./index.cjs", }, manifest: { id: "alpha", @@ -857,7 +886,7 @@ describe("bundled channel entry shape guards", () => { }, }, ], - resolveBundledPluginGeneratedPath: () => modulePath, + resolveBundledChannelGeneratedPath: () => modulePath, }; }); vi.doMock("../../infra/boundary-file-read.js", () => ({ @@ -870,44 +899,16 @@ describe("bundled channel entry shape guards", () => { vi.doMock("../../plugins/channel-catalog-registry.js", () => ({ listChannelCatalogEntries: () => [], })); - // jiti-loader-cache prefers native require() for compiled .js before - // falling back to jiti. This test drives plugin loading via the jiti - // mock — disable the native-require fast path so the mocked jiti loader - // is exercised instead of loading the on-disk fixture directly. - vi.doMock("../../plugins/native-module-require.js", () => ({ - isJavaScriptModulePath: () => false, - tryNativeRequireJavaScriptModule: () => ({ ok: false }), - })); let reentered = false; - vi.doMock("jiti", () => ({ - createJiti: () => { - return () => { - if (!reentered) { - reentered = true; - expect(bundled.listBundledChannelPlugins()).toEqual([]); - } - return { - default: { - kind: "bundled-channel-entry", - id: "alpha", - name: "Alpha", - description: "Alpha", - configSchema: {}, - register() {}, - loadChannelPlugin() { - return { - id: "alpha", - meta: {}, - capabilities: {}, - config: {}, - }; - }, - }, - }; - }; - }, - })); + ( + globalThis as { __openclawBundledChannelReenter?: () => void } + ).__openclawBundledChannelReenter = () => { + if (!reentered) { + reentered = true; + expect(bundled.listBundledChannelPlugins()).toEqual([]); + } + }; const bundled = await importFreshModule( import.meta.url, diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 666086b347c..87eefb24ecb 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -15,7 +15,6 @@ import { import { normalizePluginsConfig } from "../../plugins/config-state.js"; import { passesManifestOwnerBasePolicy } from "../../plugins/manifest-owner-policy.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; -import { isJavaScriptModulePath } from "../../plugins/native-module-require.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js"; @@ -209,8 +208,6 @@ function loadGeneratedBundledChannelModule(params: { modulePath, rootDir: boundaryRoot, boundaryRootDir: boundaryRoot, - shouldTryNativeRequire: (safePath) => - safePath.includes(`${path.sep}dist${path.sep}`) && isJavaScriptModulePath(safePath), }); } diff --git a/src/channels/plugins/module-loader.test.ts b/src/channels/plugins/module-loader.test.ts index 4217c46c5c8..7e5e2403fdb 100644 --- a/src/channels/plugins/module-loader.test.ts +++ b/src/channels/plugins/module-loader.test.ts @@ -13,7 +13,6 @@ afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); } vi.resetModules(); - vi.doUnmock("jiti"); }); function createTempDir(): string { @@ -38,7 +37,7 @@ describe("channel plugin module loader helpers", () => { expect(isJavaScriptModulePath("/tmp/entry.ts")).toBe(false); }); - it("uses native require for eligible JavaScript modules before falling back to Jiti", async () => { + it("uses native require for eligible JavaScript modules without creating Jiti", async () => { const createJiti = vi.fn(() => vi.fn(() => ({ ok: false }))); vi.resetModules(); vi.doMock("jiti", () => ({ @@ -57,45 +56,34 @@ describe("channel plugin module loader helpers", () => { loaderModule.loadChannelPluginModule({ modulePath, rootDir, - shouldTryNativeRequire: () => true, }), ).toEqual({ ok: true }); expect(createJiti).not.toHaveBeenCalled(); }); - it("creates the runtime-supported Jiti boundary for Windows dist loads", async () => { - const createJiti = vi.fn(() => vi.fn(() => ({ ok: true }))); + it("rejects TypeScript modules without creating Jiti", async () => { + const createJiti = vi.fn(() => { + throw new Error("channel module loader must not create jiti"); + }); vi.resetModules(); vi.doMock("jiti", () => ({ createJiti, })); - const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const loaderModule = await importFreshModule( + import.meta.url, + "./module-loader.js?scope=source-ts-native-hook", + ); + const rootDir = createTempDir(); + const modulePath = path.join(rootDir, "extensions", "demo", "index.ts"); + fs.mkdirSync(path.dirname(modulePath), { recursive: true }); + fs.writeFileSync(modulePath, "export const ok = true;\n", "utf8"); - try { - const loaderModule = await importFreshModule( - import.meta.url, - "./module-loader.js?scope=windows-dist-jiti", - ); - const rootDir = createTempDir(); - const modulePath = path.join(rootDir, "dist", "extensions", "demo", "index.js"); - fs.mkdirSync(path.dirname(modulePath), { recursive: true }); - fs.writeFileSync(modulePath, "export const ok = true;\n", "utf8"); - - const loaded = loaderModule.loadChannelPluginModule({ + expect(() => + loaderModule.loadChannelPluginModule({ modulePath, rootDir, - shouldTryNativeRequire: () => false, - }); - - expect(loaded).toMatchObject({ ok: true }); - expect(createJiti).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - tryNative: false, - }), - ); - } finally { - platformSpy.mockRestore(); - } + }), + ).toThrow(/must be built JavaScript/u); + expect(createJiti).not.toHaveBeenCalled(); }); }); diff --git a/src/channels/plugins/module-loader.ts b/src/channels/plugins/module-loader.ts index 575395ee8ff..0e8205cd8dc 100644 --- a/src/channels/plugins/module-loader.ts +++ b/src/channels/plugins/module-loader.ts @@ -1,23 +1,31 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { openBoundaryFileSync } from "../../infra/boundary-file-read.js"; -import { - getCachedPluginJitiLoader, - type PluginJitiLoaderCache, -} from "../../plugins/jiti-loader-cache.js"; +import { isJavaScriptModulePath } from "../../plugins/native-module-require.js"; -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const nodeRequire = createRequire(import.meta.url); +const SOURCE_MODULE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]); -function loadModule(modulePath: string, tryNative?: boolean) { - return getCachedPluginJitiLoader({ - cache: jitiLoaders, - modulePath, - importerUrl: import.meta.url, - argvEntry: process.argv[1], - preferBuiltDist: true, - jitiFilename: import.meta.url, - tryNative, - }); +function hasNativeSourceRequireHook(modulePath: string): boolean { + const extension = path.extname(modulePath).toLowerCase(); + return ( + SOURCE_MODULE_EXTENSIONS.has(extension) && + typeof nodeRequire.extensions?.[extension] === "function" + ); +} + +function loadModule(modulePath: string): unknown { + if (!isJavaScriptModulePath(modulePath) && !hasNativeSourceRequireHook(modulePath)) { + throw new Error(`channel plugin module must be built JavaScript: ${modulePath}`); + } + try { + return nodeRequire(modulePath); + } catch (error) { + throw new Error(`failed to load channel plugin module with native require: ${modulePath}`, { + cause: error, + }); + } } function resolvePluginModuleCandidates(rootDir: string, specifier: string): string[] { @@ -52,7 +60,6 @@ export function loadChannelPluginModule(params: { rootDir: string; boundaryRootDir?: string; boundaryLabel?: string; - shouldTryNativeRequire?: (safePath: string) => boolean; }): unknown { const opened = openBoundaryFileSync({ absolutePath: params.modulePath, @@ -68,5 +75,5 @@ export function loadChannelPluginModule(params: { } const safePath = opened.path; fs.closeSync(opened.fd); - return loadModule(safePath, params.shouldTryNativeRequire?.(safePath))(safePath); + return loadModule(safePath); } diff --git a/src/channels/plugins/package-state-probes.ts b/src/channels/plugins/package-state-probes.ts index 2f30fff0eab..5ebdb9dc786 100644 --- a/src/channels/plugins/package-state-probes.ts +++ b/src/channels/plugins/package-state-probes.ts @@ -5,7 +5,6 @@ import { listChannelCatalogEntries, type PluginChannelCatalogEntry, } from "../../plugins/channel-catalog-registry.js"; -import { isJavaScriptModulePath } from "../../plugins/native-module-require.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { loadChannelPluginModule, resolveExistingPluginModulePath } from "./module-loader.js"; @@ -60,7 +59,6 @@ function resolveChannelPackageStateChecker(params: { const moduleExport = loadChannelPluginModule({ modulePath: resolveExistingPluginModulePath(params.entry.rootDir, metadata.specifier!), rootDir: params.entry.rootDir, - shouldTryNativeRequire: isJavaScriptModulePath, }) as Record; const checker = moduleExport[metadata.exportName!] as ChannelPackageStateChecker | undefined; if (typeof checker !== "function") {