diff --git a/src/auto-reply/reply/commands-gating.test.ts b/src/auto-reply/reply/commands-gating.test.ts index 11ec1b09fb5..2aa204596b3 100644 --- a/src/auto-reply/reply/commands-gating.test.ts +++ b/src/auto-reply/reply/commands-gating.test.ts @@ -66,6 +66,7 @@ vi.mock("../../channels/plugins/config-writes.js", () => ({ })); vi.mock("../../channels/registry.js", () => ({ + normalizeAnyChannelId: vi.fn((value?: string) => value), normalizeChannelId: vi.fn((value?: string) => value), })); diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 74a1c35da2a..b59dae9e861 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -120,7 +120,7 @@ export function listPotentialConfiguredChannelPresenceSignals( signals.push({ channelId, source }); }; const configuredChannelIds = new Set(); - const channelIds = listBundledChannelPluginIds(); + const channelIds = listBundledChannelPluginIds(env); const channelEnvPrefixes = listChannelEnvPrefixes(channelIds); const channels = isRecord(cfg.channels) ? cfg.channels : null; if (channels) { @@ -164,7 +164,7 @@ function hasEnvConfiguredChannel( env: NodeJS.ProcessEnv, options: ChannelPresenceOptions = {}, ): boolean { - const channelIds = listBundledChannelPluginIds(); + const channelIds = listBundledChannelPluginIds(env); const channelEnvPrefixes = listChannelEnvPrefixes(channelIds); for (const [key, value] of Object.entries(env)) { if (!hasNonEmptyString(value)) { diff --git a/src/channels/plugins/bundled-ids.ts b/src/channels/plugins/bundled-ids.ts index 0353ae8ea64..5cc75528eb6 100644 --- a/src/channels/plugins/bundled-ids.ts +++ b/src/channels/plugins/bundled-ids.ts @@ -10,6 +10,6 @@ export function listBundledChannelPluginIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } -export function listBundledChannelPluginIds(): string[] { - return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope().cacheKey); +export function listBundledChannelPluginIds(env: NodeJS.ProcessEnv = process.env): string[] { + return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env); } diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 1f569688be1..7fc2bced245 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -14,7 +14,7 @@ import { listReadOnlyChannelPluginsForConfig, } from "./read-only.js"; -const jitiLoaderParams = vi.hoisted( +const moduleLoaderParams = vi.hoisted( () => [] as Array<{ modulePath: string; @@ -31,8 +31,9 @@ vi.mock("../../plugins/bundled-dir.js", async (importOriginal) => { }; }); -vi.mock("../../plugins/jiti-loader-cache.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => { + const actual = + await importOriginal(); const { createRequire } = await import("node:module"); const require = createRequire(import.meta.url); @@ -105,12 +106,12 @@ vi.mock("../../plugins/jiti-loader-cache.js", async (importOriginal) => { return { ...actual, - getCachedPluginJitiLoader: ((params) => { - jitiLoaderParams.push({ + getCachedPluginModuleLoader: ((params) => { + moduleLoaderParams.push({ modulePath: params.modulePath, tryNative: params.tryNative, }); - const actualLoader = actual.getCachedPluginJitiLoader(params); + const actualLoader = actual.getCachedPluginModuleLoader(params); return ((modulePath: string) => { if ( modulePath.endsWith("/plugins/loader.js") || @@ -119,8 +120,8 @@ vi.mock("../../plugins/jiti-loader-cache.js", async (importOriginal) => { return { loadOpenClawPlugins }; } return actualLoader(modulePath); - }) as ReturnType; - }) satisfies typeof actual.getCachedPluginJitiLoader, + }) as ReturnType; + }) satisfies typeof actual.getCachedPluginModuleLoader, }; }); @@ -431,7 +432,7 @@ function expectExternalChatSetupOnlyPluginLoaded(params: { } afterEach(() => { - jitiLoaderParams.length = 0; + moduleLoaderParams.length = 0; resetPluginLoaderTestStateForTest(); }); @@ -498,7 +499,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expectExternalChatSetupOnlyPluginLoaded({ plugins, setupMarker, fullMarker }); expect( - jitiLoaderParams.some( + moduleLoaderParams.some( (entry) => entry.tryNative === true && (entry.modulePath.endsWith("/plugins/loader.js") || diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 00bc85d5b2f..00b9fcdf771 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -8,11 +8,11 @@ import { listConfiguredChannelIdsForReadOnlyScope, resolveDiscoverableScopedChannelPluginIds, } from "../../plugins/channel-plugin-ids.js"; -import { - getCachedPluginJitiLoader, - type PluginJitiLoaderCache, -} from "../../plugins/jiti-loader-cache.js"; import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import { + getCachedPluginModuleLoader, + type PluginModuleLoaderCache, +} from "../../plugins/plugin-module-loader-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; @@ -34,7 +34,7 @@ const BUILT_PLUGIN_LOADER_MODULE_CANDIDATES = [ "plugins/loader.js", "plugins/build-smoke-entry.js", ] as const; -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const moduleLoaders: PluginModuleLoaderCache = new Map(); type PluginLoaderModule = { loadOpenClawPlugins: (params: { @@ -93,15 +93,15 @@ function loadPluginLoaderModule(): PluginLoaderModule { for (const candidate of listPluginLoaderModuleCandidateUrls()) { const modulePath = fileURLToPath(candidate); try { - const jiti = getCachedPluginJitiLoader({ - cache: jitiLoaders, + const moduleLoader = getCachedPluginModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, preferBuiltDist: true, - jitiFilename: import.meta.url, + loaderFilename: import.meta.url, tryNative: true, }); - pluginLoaderModule = jiti(modulePath) as PluginLoaderModule; + pluginLoaderModule = moduleLoader(modulePath) as PluginLoaderModule; return pluginLoaderModule; } catch { // Try built/runtime source candidates in order. diff --git a/src/commands/channel-setup/workspace-shadow-bypass.test.ts b/src/commands/channel-setup/workspace-shadow-bypass.test.ts index 78cb990846a..7c919993f74 100644 --- a/src/commands/channel-setup/workspace-shadow-bypass.test.ts +++ b/src/commands/channel-setup/workspace-shadow-bypass.test.ts @@ -35,6 +35,7 @@ vi.mock("../../channels/plugins/catalog.js", () => ({ })); vi.mock("../../channels/registry.js", () => ({ listChatChannels: () => listChatChannels(), + normalizeAnyChannelId: (channelId?: string) => channelId?.trim().toLowerCase() ?? null, })); vi.mock("../../plugins/manifest-registry.js", () => ({ loadPluginManifestRegistry: (...a: unknown[]) => loadPluginManifestRegistry(...a), diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index ac75fef8ed9..aaad8a48bd9 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -12,8 +12,31 @@ import { makeRegistry, resetPluginAutoEnableTestState, } from "./plugin-auto-enable.test-helpers.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; import { validateConfigObject } from "./validation.js"; +vi.mock("../channels/plugins/configured-state.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasBundledChannelConfiguredState: (params: { + channelId: string; + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + }) => { + if (params.channelId === "irc") { + return Boolean(params.env?.IRC_HOST?.trim() && params.env?.IRC_NICK?.trim()); + } + if (params.channelId === "slack") { + return ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some((key) => + Boolean(params.env?.[key]?.trim()), + ); + } + return actual.hasBundledChannelConfiguredState(params); + }, + }; +}); + const env = makeIsolatedEnv(); afterAll(() => { @@ -93,6 +116,19 @@ describe("applyPluginAutoEnable core", () => { expect(result.config.plugins?.allow).toBeUndefined(); }); + it("does not auto-enable Slack from unrelated Slack-prefixed env vars", () => { + const result = applyPluginAutoEnable({ + config: {}, + env: makeIsolatedEnv({ + SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/T000/B000/XXX", + }), + }); + + expect(result.config.channels?.slack).toBeUndefined(); + expect(result.config.plugins?.entries?.slack).toBeUndefined(); + expect(result.changes).toEqual([]); + }); + it("stores auto-enable reasons in a null-prototype dictionary", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 6c7ae9e15d9..81767fd7d54 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -2,7 +2,7 @@ import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtime import { normalizeProviderId } from "../agents/provider-id.js"; import { hasPotentialConfiguredChannels, - listPotentialConfiguredChannelIds, + listPotentialConfiguredChannelPresenceSignals, } from "../channels/config-presence.js"; import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js"; import { @@ -294,10 +294,12 @@ function collectPluginIdsForConfiguredChannel( return [builtInId ?? claims[0]?.plugin.id ?? normalizedChannelId]; } -function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { - return listPotentialConfiguredChannelIds(cfg, env, { includePersistedAuthState: false }).map( - (channelId) => normalizeChatChannelId(channelId) ?? channelId, - ); +function collectConfiguredChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { + return listPotentialConfiguredChannelPresenceSignals(cfg, env, { + includePersistedAuthState: false, + }) + .map((signal) => normalizeChatChannelId(signal.channelId) ?? signal.channelId) + .filter((channelId) => isChannelConfigured(cfg, channelId, env)); } function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean { @@ -574,11 +576,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { registry: PluginManifestRegistry; }): PluginAutoEnableCandidate[] { const changes: PluginAutoEnableCandidate[] = []; - for (const channelId of collectCandidateChannelIds(params.config, params.env)) { - if (isChannelConfigured(params.config, channelId, params.env)) { - for (const pluginId of collectPluginIdsForConfiguredChannel(channelId, params.registry)) { - changes.push({ pluginId, kind: "channel-configured", channelId }); - } + for (const channelId of collectConfiguredChannelIds(params.config, params.env)) { + for (const pluginId of collectPluginIdsForConfiguredChannel(channelId, params.registry)) { + changes.push({ pluginId, kind: "channel-configured", channelId }); } } diff --git a/src/config/plugin-auto-enable.test-helpers.ts b/src/config/plugin-auto-enable.test-helpers.ts index 4be77ec2a79..46804fdcb1b 100644 --- a/src/config/plugin-auto-enable.test-helpers.ts +++ b/src/config/plugin-auto-enable.test-helpers.ts @@ -19,6 +19,7 @@ export function makeIsolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.Proce return { OPENCLAW_STATE_DIR: path.join(rootDir, "state"), OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(process.cwd(), "extensions"), + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", VITEST: "true", ...overrides, }; diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 305378d99d2..38e9bc40352 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -45,6 +45,7 @@ vi.mock("../channels/registry.js", () => ({ meta: Parameters[0], docsLink: Parameters[1], ) => formatChannelSelectionLine(meta, docsLink), + normalizeAnyChannelId: (channelId?: string) => channelId?.trim().toLowerCase() ?? null, })); vi.mock("../commands/channel-setup/discovery.js", () => ({ diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 3eb5968cfa3..864d8452048 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -134,6 +134,8 @@ vi.mock("../channels/plugins/setup-registry.js", () => ({ vi.mock("../channels/registry.js", () => ({ getChatChannelMeta: (channelId: string) => ({ id: channelId, label: channelId }), listChatChannels: () => [], + normalizeAnyChannelId: (channelId?: unknown) => + typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null, normalizeChatChannelId: (channelId?: unknown) => typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null, })); diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 524b73ceb73..154460da047 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -213,7 +213,7 @@ async function expectBuiltArtifactNodeRequireFastPath( const importerPath = path.join(pluginRoot, "index.js"); const sidecarPath = path.join(pluginRoot, "fast-path-sidecar.cjs"); fs.writeFileSync(importerPath, "export default {};\n", "utf8"); - // CommonJS so `nodeRequire` succeeds without falling back to jiti, even + // CommonJS so `nodeRequire` succeeds without falling back to the source loader, even // inside built plugin artifacts with a `type: "module"` package boundary. fs.writeFileSync(sidecarPath, "module.exports = { sentinel: 7 };\n", "utf8"); @@ -228,10 +228,10 @@ async function expectBuiltArtifactNodeRequireFastPath( .map((args) => String(args[0] ?? "")) .find((line) => line.startsWith("[plugin-load-profile] phase=bundled-entry-module-load")); expect(profileLine, "expected a bundled-entry-module-load profile line").toBeDefined(); - expect(profileLine).toMatch(/getJitiMs=\d/u); - expect(profileLine).toMatch(/jitiCallMs=\d/u); - expect(profileLine).not.toMatch(/getJitiMs=-/); - expect(profileLine).not.toMatch(/jitiCallMs=-/); + expect(profileLine).toMatch(/sourceLoaderCreateMs=\d/u); + expect(profileLine).toMatch(/sourceLoaderCallMs=\d/u); + expect(profileLine).not.toMatch(/sourceLoaderCreateMs=-/); + expect(profileLine).not.toMatch(/sourceLoaderCallMs=-/); } finally { errorSpy.mockRestore(); } @@ -267,7 +267,7 @@ describe("loadBundledEntryExportSync", () => { expect(message).toContain("ENOENT"); }); - it("keeps Windows dist sidecar loads off Jiti native import", async () => { + it("keeps Windows dist sidecar loads off source-transform loading", async () => { const createJiti = vi.fn(() => vi.fn(() => ({ load: 42 }))); vi.doMock("jiti", () => ({ createJiti, @@ -306,7 +306,7 @@ describe("loadBundledEntryExportSync", () => { } }); - it("normalizes Windows absolute sidecar paths before Jiti loads them", async () => { + it("normalizes Windows absolute sidecar paths before module loads them", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-entry-contract-")); tempDirs.push(tempRoot); const openedFdPath = path.join(tempRoot, "opened"); @@ -396,10 +396,10 @@ describe("loadBundledEntryExportSync", () => { }); }); - it("emits non-negative jiti sub-step timings on the built-artifact load path", async () => { + it("emits non-negative source-loader sub-step timings on the built-artifact load path", async () => { // Built artifacts prefer `nodeRequire`, but Node can still reject a sidecar // and fall back through jiti. The profile line must never report negative - // or missing jiti sub-step timings either way. + // or missing source-loader sub-step timings either way. await expectBuiltArtifactNodeRequireFastPath("built-artifact-profile-fast-path"); }); diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index fc1dd9a8be4..9e5e47d398b 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -8,15 +8,15 @@ import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types. import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; -import { - getCachedPluginJitiLoader, - type PluginJitiLoaderCache, -} from "../plugins/jiti-loader-cache.js"; import { createProfiler, formatPluginLoadProfileLine, shouldProfilePluginLoader, } from "../plugins/plugin-load-profile.js"; +import { + getCachedPluginSourceModuleLoader, + type PluginModuleLoaderCache, +} from "../plugins/plugin-module-loader-cache.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js"; import type { AnyAgentTool, OpenClawPluginApi, PluginCommandContext } from "../plugins/types.js"; @@ -125,7 +125,7 @@ export type BundledChannelSetupEntryContract = { export type BundledEntryModuleLoadOptions = Record; const nodeRequire = createRequire(import.meta.url); -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const moduleLoaders: PluginModuleLoaderCache = new Map(); const loadedModuleExports = new Map(); const disableBundledEntrySourceFallbackEnv = "OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK"; @@ -319,13 +319,13 @@ function resolveBundledEntryModulePath(importMetaUrl: string, specifier: string) ); } -function getJiti(modulePath: string) { - return getCachedPluginJitiLoader({ - cache: jitiLoaders, +function getSourceModuleLoader(modulePath: string) { + return getCachedPluginSourceModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, preferBuiltDist: true, - jitiFilename: import.meta.url, + loaderFilename: import.meta.url, }); } @@ -352,25 +352,25 @@ function loadBundledEntryModuleSync( let loaded: unknown; const profile = shouldProfilePluginLoader(); const loadStartMs = profile ? performance.now() : 0; - let getJitiEndMs = 0; + let sourceLoaderReadyMs = 0; if (canTryNodeRequireBuiltModule(modulePath)) { try { loaded = nodeRequire(modulePath); } catch { - const jiti = getJiti(modulePath); - getJitiEndMs = profile ? performance.now() : 0; - loaded = jiti(toSafeImportPath(modulePath)); + const moduleLoader = getSourceModuleLoader(modulePath); + sourceLoaderReadyMs = profile ? performance.now() : 0; + loaded = moduleLoader(toSafeImportPath(modulePath)); } } else { - const jiti = getJiti(modulePath); - getJitiEndMs = profile ? performance.now() : 0; - loaded = jiti(toSafeImportPath(modulePath)); + const moduleLoader = getSourceModuleLoader(modulePath); + sourceLoaderReadyMs = profile ? performance.now() : 0; + loaded = moduleLoader(toSafeImportPath(modulePath)); } if (profile) { const endMs = performance.now(); // Use shared formatter — but split timing fields ourselves so we can - // attribute time spent in `getJiti(...)` factory creation vs the actual - // graph-walking `__j(modulePath)` call. Both are emitted as extras + // attribute time spent in source-loader creation vs the actual graph load. + // Both are emitted as extras // alongside the canonical `elapsedMs=` field. console.error( formatPluginLoadProfileLine({ @@ -378,15 +378,12 @@ function loadBundledEntryModuleSync( pluginId: "(bundled-entry)", source: modulePath, elapsedMs: endMs - loadStartMs, - // When the built-artifact fast-path resolves the module via `nodeRequire`, - // `getJitiEndMs` stays `0` because the `catch` block (the only place - // it gets stamped) never runs. Reporting `getJitiMs` / - // `jitiCallMs` as `0` for that path keeps the breakdown honest: - // `elapsedMs=` already captures the nodeRequire time, and we don't - // want to mis-attribute it to jiti sub-steps. + // When the built-artifact fast path resolves via `nodeRequire`, the + // source-loader timestamp stays `0`; keep its breakdown at zero so + // `elapsedMs=` owns the native load time. extras: [ - ["getJitiMs", getJitiEndMs ? getJitiEndMs - loadStartMs : 0], - ["jitiCallMs", getJitiEndMs ? endMs - getJitiEndMs : 0], + ["sourceLoaderCreateMs", sourceLoaderReadyMs ? sourceLoaderReadyMs - loadStartMs : 0], + ["sourceLoaderCallMs", sourceLoaderReadyMs ? endMs - sourceLoaderReadyMs : 0], ], }), ); diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index ce18edc39b0..4860ea07d77 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -6,7 +6,7 @@ import { listImportedBundledPluginFacadeIds, loadBundledPluginPublicSurfaceModuleSync, resetFacadeLoaderStateForTest, - setFacadeLoaderJitiFactoryForTest, + setFacadeLoaderSourceTransformFactoryForTest, } from "./facade-loader.js"; import { listImportedBundledPluginFacadeIds as listImportedFacadeRuntimeIds } from "./facade-runtime.js"; import { createPluginSdkTestHarness } from "./test-helpers.js"; @@ -15,7 +15,9 @@ const { createTempDirSync } = createPluginSdkTestHarness(); const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; const FACADE_LOADER_GLOBAL = "__openclawTestLoadBundledPluginPublicSurfaceModuleSync"; -type FacadeLoaderJitiFactory = NonNullable[0]>; +type FacadeLoaderSourceTransformFactory = NonNullable< + Parameters[0] +>; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const trustedBundledPluginFixtureRoots: string[] = []; let trustedPluginIdCounter = 0; @@ -159,7 +161,7 @@ function writeJsonFile(filePath: string, value: unknown): void { afterEach(() => { vi.restoreAllMocks(); resetFacadeLoaderStateForTest(); - setFacadeLoaderJitiFactoryForTest(undefined); + setFacadeLoaderSourceTransformFactoryForTest(undefined); for (const dir of trustedBundledPluginFixtureRoots.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -265,13 +267,13 @@ describe("plugin-sdk facade loader", () => { }); process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; - const createJitiCalls: Parameters[] = []; - setFacadeLoaderJitiFactoryForTest(((...args) => { + const createJitiCalls: Parameters[] = []; + setFacadeLoaderSourceTransformFactoryForTest(((...args) => { createJitiCalls.push(args); return vi.fn(() => ({ marker: "jiti-fallback", - })) as unknown as ReturnType; - }) as FacadeLoaderJitiFactory); + })) as unknown as ReturnType; + }) as FacadeLoaderSourceTransformFactory); const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const restoreVersions = forceNodeRuntimeVersionsForTest(); diff --git a/src/plugin-sdk/facade-loader.ts b/src/plugin-sdk/facade-loader.ts index 571d4ba3a92..f7672070fbf 100644 --- a/src/plugin-sdk/facade-loader.ts +++ b/src/plugin-sdk/facade-loader.ts @@ -5,29 +5,29 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; import { - getCachedPluginJitiLoader, - type PluginJitiLoaderCache, - type PluginJitiLoaderFactory, -} from "../plugins/jiti-loader-cache.js"; + getCachedPluginModuleLoader, + type PluginModuleLoaderCache, + type PluginModuleLoaderFactory, +} from "../plugins/plugin-module-loader-cache.js"; import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js"; import { resolveBundledFacadeModuleLocation } from "./facade-resolution-shared.js"; const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); const nodeRequire = createRequire(import.meta.url); -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const moduleLoaders: PluginModuleLoaderCache = new Map(); const loadedFacadeModules = new Map(); const loadedFacadePluginIds = new Set(); -let facadeLoaderJitiFactory: PluginJitiLoaderFactory | undefined; +let facadeLoaderSourceTransformFactory: PluginModuleLoaderFactory | undefined; let cachedOpenClawPackageRoot: string | undefined; -function getJitiFactory() { - if (facadeLoaderJitiFactory) { - return facadeLoaderJitiFactory; +function getSourceTransformFactory() { + if (facadeLoaderSourceTransformFactory) { + return facadeLoaderSourceTransformFactory; } const { createJiti } = nodeRequire("jiti") as typeof import("jiti"); - facadeLoaderJitiFactory = createJiti; - return facadeLoaderJitiFactory; + facadeLoaderSourceTransformFactory = createJiti; + return facadeLoaderSourceTransformFactory; } function getOpenClawPackageRoot() { @@ -56,14 +56,14 @@ function resolveFacadeModuleLocation(params: { }); } -function getJiti(modulePath: string) { - return getCachedPluginJitiLoader({ - cache: jitiLoaders, +function getModuleLoader(modulePath: string) { + return getCachedPluginModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, preferBuiltDist: true, - jitiFilename: import.meta.url, - createLoader: getJitiFactory(), + loaderFilename: import.meta.url, + createLoader: getSourceTransformFactory(), }); } @@ -173,7 +173,7 @@ export function loadFacadeModuleAtLocationSync(params: { try { loaded = params.loadModule?.(location.modulePath) ?? - (getJiti(location.modulePath)(location.modulePath) as T); + (getModuleLoader(location.modulePath)(location.modulePath) as T); Object.assign(sentinel, loaded); loadedFacadePluginIds.add( typeof params.trackedPluginId === "function" @@ -264,13 +264,13 @@ export function listImportedBundledPluginFacadeIds(): string[] { export function resetFacadeLoaderStateForTest(): void { loadedFacadeModules.clear(); loadedFacadePluginIds.clear(); - jitiLoaders.clear(); - facadeLoaderJitiFactory = undefined; + moduleLoaders.clear(); + facadeLoaderSourceTransformFactory = undefined; cachedOpenClawPackageRoot = undefined; } -export function setFacadeLoaderJitiFactoryForTest( - factory: PluginJitiLoaderFactory | undefined, +export function setFacadeLoaderSourceTransformFactoryForTest( + factory: PluginModuleLoaderFactory | undefined, ): void { - facadeLoaderJitiFactory = factory; + facadeLoaderSourceTransformFactory = factory; } diff --git a/src/plugin-sdk/facade-runtime.ts b/src/plugin-sdk/facade-runtime.ts index 1abb4d5789d..de1f40e646a 100644 --- a/src/plugin-sdk/facade-runtime.ts +++ b/src/plugin-sdk/facade-runtime.ts @@ -3,9 +3,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; import { - getCachedPluginJitiLoader, - type PluginJitiLoaderCache, -} from "../plugins/jiti-loader-cache.js"; + getCachedPluginSourceModuleLoader, + type PluginModuleLoaderCache, +} from "../plugins/plugin-module-loader-cache.js"; import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js"; import { loadBundledPluginPublicSurfaceModuleSync as loadBundledPluginPublicSurfaceModuleSyncLight, @@ -110,16 +110,15 @@ const FACADE_ACTIVATION_CHECK_RUNTIME_CANDIDATES = [ ] as const; let facadeActivationCheckRuntimeModule: FacadeActivationCheckRuntimeModule | undefined; -const facadeActivationCheckRuntimeJitiLoaders: PluginJitiLoaderCache = new Map(); +const facadeActivationCheckRuntimeLoaders: PluginModuleLoaderCache = new Map(); -function getFacadeActivationCheckRuntimeJiti(modulePath: string) { - return getCachedPluginJitiLoader({ - cache: facadeActivationCheckRuntimeJitiLoaders, +function getFacadeActivationCheckRuntimeSourceLoader(modulePath: string) { + return getCachedPluginSourceModuleLoader({ + cache: facadeActivationCheckRuntimeLoaders, modulePath, importerUrl: import.meta.url, - jitiFilename: import.meta.url, + loaderFilename: import.meta.url, aliasMap: {}, - tryNative: false, }); } @@ -149,7 +148,7 @@ function loadFacadeActivationCheckRuntime(): FacadeActivationCheckRuntimeModule return facadeActivationCheckRuntimeModule; } facadeActivationCheckRuntimeModule = loadFacadeActivationCheckRuntimeFromCandidates((candidate) => - getFacadeActivationCheckRuntimeJiti(candidate)(candidate), + getFacadeActivationCheckRuntimeSourceLoader(candidate)(candidate), ); if (facadeActivationCheckRuntimeModule) { return facadeActivationCheckRuntimeModule; @@ -246,7 +245,7 @@ export function tryLoadActivatedBundledPluginPublicSurfaceModuleSync { + const getModuleLoader = (modulePath: string) => { const tryNative = - shouldPreferNativeJiti(modulePath) && !(env?.VITEST && params.pluginSdkResolution === "dist"); + shouldPreferNativeModuleLoad(modulePath) && + !(env?.VITEST && params.pluginSdkResolution === "dist"); const aliasMap = shouldApplyVitestCapabilityAliasOverrides({ pluginSdkResolution: params.pluginSdkResolution, env, @@ -213,11 +217,11 @@ export function loadBundledCapabilityRuntimeRegistry(params: { env, }) : undefined; - return getCachedPluginJitiLoader({ - cache: jitiLoaders, + return getCachedPluginModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, - jitiFilename: import.meta.url, + loaderFilename: import.meta.url, ...(aliasMap ? { aliasMap } : {}), pluginSdkResolution: params.pluginSdkResolution, tryNative, @@ -289,7 +293,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: { let mod: OpenClawPluginModule | null = null; try { - mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule; + mod = getModuleLoader(safeSource)(safeSource) as OpenClawPluginModule; } catch (error) { recordCapabilityLoadError(registry, record, String(error)); continue; diff --git a/src/plugins/bundled-channel-config-metadata.ts b/src/plugins/bundled-channel-config-metadata.ts index 8e8b7d4d7e9..847cc449e1e 100644 --- a/src/plugins/bundled-channel-config-metadata.ts +++ b/src/plugins/bundled-channel-config-metadata.ts @@ -7,13 +7,16 @@ import { normalizeBundledPluginStringList, trimBundledPluginString, } from "./bundled-plugin-scan.js"; -import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import type { PluginConfigUiHint } from "./manifest-types.js"; import type { OpenClawPackageManifest, PluginManifest, PluginManifestChannelConfig, } from "./manifest.js"; +import { + getCachedPluginModuleLoader, + type PluginModuleLoaderCache, +} from "./plugin-module-loader-cache.js"; import { PUBLIC_SURFACE_SOURCE_EXTENSIONS } from "./public-surface-runtime.js"; const SOURCE_CONFIG_SCHEMA_CANDIDATES = [ @@ -32,7 +35,7 @@ type ChannelConfigSurface = { runtime?: ChannelConfigRuntimeSchema; }; -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const moduleLoaders: PluginModuleLoaderCache = new Map(); function isBuiltChannelConfigSchema(value: unknown): value is ChannelConfigSurface { if (!value || typeof value !== "object") { @@ -70,13 +73,13 @@ function resolveConfigSchemaExport(imported: Record): ChannelCo return null; } -function getJiti(modulePath: string) { - return getCachedPluginJitiLoader({ - cache: jitiLoaders, +function getModuleLoader(modulePath: string) { + return getCachedPluginModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, preferBuiltDist: true, - jitiFilename: import.meta.url, + loaderFilename: import.meta.url, }); } @@ -100,7 +103,7 @@ function resolveChannelConfigSchemaModulePath(pluginDir: string): string | undef function loadChannelConfigSurfaceModuleSync(modulePath: string): ChannelConfigSurface | null { try { - const imported = getJiti(modulePath)(modulePath) as Record; + const imported = getModuleLoader(modulePath)(modulePath) as Record; return resolveConfigSchemaExport(imported); } catch { return null; diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index ab85babe730..100cbc39cd4 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -23,7 +23,7 @@ afterEach(() => { cleanupTrackedTempDirs(tempDirs); }); -describe("doctor-contract-registry getJiti", () => { +describe("doctor-contract-registry module loader", () => { beforeEach(async () => { resetRegistryJitiMocks(); vi.resetModules(); @@ -67,7 +67,7 @@ describe("doctor-contract-registry getJiti", () => { expect(mocks.createJiti).not.toHaveBeenCalled(); }); - it("falls back to the Jiti boundary on Windows for TypeScript contract-api modules", () => { + it("falls back to the source-transform boundary on Windows for TypeScript contract-api modules", () => { const pluginRoot = makeTempDir(); const contractApiPath = path.join(pluginRoot, "contract-api.ts"); fs.writeFileSync( diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 58f70eeaa32..e17806ccb5f 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -4,8 +4,11 @@ import { fileURLToPath } from "node:url"; import type { LegacyConfigRule } from "../config/legacy.shared.js"; import type { OpenClawConfig } from "../config/types.js"; import { asNullableRecord } from "../shared/record-coerce.js"; -import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import type { PluginManifestRegistry } from "./manifest-registry.js"; +import { + getCachedPluginModuleLoader, + type PluginModuleLoaderCache, +} from "./plugin-module-loader-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; const CONTRACT_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const; @@ -36,11 +39,11 @@ type PluginDoctorContractEntry = { type PluginManifestRegistryRecord = PluginManifestRegistry["plugins"][number]; -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const moduleLoaders: PluginModuleLoaderCache = new Map(); function loadPluginDoctorContractModule(modulePath: string): PluginDoctorContractModule { - return getCachedPluginJitiLoader({ - cache: jitiLoaders, + return getCachedPluginModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, })(modulePath) as PluginDoctorContractModule; @@ -228,7 +231,7 @@ function resolvePluginDoctorContracts(params?: { } export function clearPluginDoctorContractRegistryCache(): void { - jitiLoaders.clear(); + moduleLoaders.clear(); } export function listPluginDoctorLegacyConfigRules(params?: { diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index c97745ee4a8..1c6dd221b37 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -1872,7 +1872,14 @@ describe("installPluginFromArchive", () => { }); it("does not flag the real qa-matrix plugin as dangerous install code", async () => { - const pluginDir = path.resolve(process.cwd(), "extensions", "qa-matrix"); + const sourcePluginDir = path.resolve(process.cwd(), "extensions", "qa-matrix"); + const pluginDir = path.join(suiteTempRootTracker.makeTempDir(), "qa-matrix"); + fs.cpSync(sourcePluginDir, pluginDir, { + recursive: true, + filter: (entryPath) => + !path.relative(sourcePluginDir, entryPath).split(path.sep).includes("node_modules"), + }); + vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(process.cwd()); const scanResult = await installSecurityScan.scanPackageInstallSource({ extensions: ["./index.ts"], diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 0bd17698966..46c6f877366 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -12,7 +12,6 @@ import { isPrereleaseResolutionAllowed, parseRegistryNpmSpec, } from "../infra/npm-registry-spec.js"; -import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { createSafeNpmInstallArgs, createSafeNpmInstallEnv, @@ -35,6 +34,7 @@ import { type PackageManifest as PluginPackageManifest, } from "./manifest.js"; import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js"; +import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js"; export { resolvePluginInstallDir } from "./install-paths.js"; @@ -552,50 +552,6 @@ async function detectNativePackageInstallSource(packageDir: string): Promise; - logger: PluginInstallLogger; -}): Promise { - const peers = Object.keys(params.peerDependencies).filter((name) => name === "openclaw"); - if (peers.length === 0) { - return; - } - - const hostRoot = resolveOpenClawPackageRootSync({ - argv1: process.argv[1], - moduleUrl: import.meta.url, - cwd: process.cwd(), - }); - if (!hostRoot) { - params.logger.warn?.( - "Could not locate openclaw package root to symlink peerDependencies; plugin may fail to resolve openclaw at runtime.", - ); - return; - } - - const nodeModulesDir = path.join(params.installedDir, "node_modules"); - await fs.mkdir(nodeModulesDir, { recursive: true }); - - for (const peerName of peers) { - const linkPath = path.join(nodeModulesDir, peerName); - - try { - // Remove any existing entry (broken link or stale directory) before - // creating the new symlink so re-installs are idempotent. - await fs.rm(linkPath, { recursive: true, force: true }); - await fs.symlink(hostRoot, linkPath, "junction"); - params.logger.info?.(`Linked peerDependency "${peerName}" -> ${hostRoot}`); - } catch (err) { - params.logger.warn?.(`Failed to symlink peerDependency "${peerName}": ${String(err)}`); - } - } -} - type ValidatedPackagePlugin = { manifest: PackageManifest; pluginId: string; diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts index 39726395bd2..6077f4550aa 100644 --- a/src/plugins/loader.git-path-regression.test.ts +++ b/src/plugins/loader.git-path-regression.test.ts @@ -27,8 +27,8 @@ describe("plugin loader git path regression", () => { const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk"); mkdirSafe(copiedSourceDir); mkdirSafe(copiedPluginSdkDir); - const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs"); - fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8"); + const sourceLoaderBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs"); + fs.writeFileSync(sourceLoaderBaseFile, "export {};\n", "utf-8"); fs.writeFileSync( path.join(copiedSourceDir, "channel.runtime.ts"), `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps"; @@ -59,7 +59,7 @@ export const copiedRuntimeMarker = { const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts"); const script = ` import { createJiti } from "jiti"; - const withoutAlias = createJiti(${JSON.stringify(jitiBaseFile)}, { + const withoutAlias = createJiti(${JSON.stringify(sourceLoaderBaseFile)}, { interopDefault: true, tryNative: false, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], @@ -70,7 +70,7 @@ export const copiedRuntimeMarker = { } catch { withoutAliasThrew = true; } - const withAlias = createJiti(${JSON.stringify(jitiBaseFile)}, { + const withAlias = createJiti(${JSON.stringify(sourceLoaderBaseFile)}, { interopDefault: true, tryNative: false, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], diff --git a/src/plugins/loader.jiti-filename.test.ts b/src/plugins/loader.jiti-filename.test.ts deleted file mode 100644 index a7d4f6ef601..00000000000 --- a/src/plugins/loader.jiti-filename.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -const tempDirs: string[] = []; - -function makeTempDir() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-loader-")); - tempDirs.push(dir); - return dir; -} - -function writeBundledPluginFixture(id: string) { - const pluginRoot = makeTempDir(); - fs.writeFileSync( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginRoot, "index.cjs"), - `module.exports = { id: ${JSON.stringify(id)}, register() {} };`, - "utf-8", - ); - return pluginRoot; -} - -afterEach(() => { - vi.resetModules(); - vi.doUnmock("./jiti-loader-cache.js"); - delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("createPluginModuleLoader", () => { - it("loads bundled JavaScript without creating a jiti loader", async () => { - const jitiLoaderCalls: Array<{ modulePath: string; jitiFilename?: string }> = []; - vi.doMock("./jiti-loader-cache.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getCachedPluginJitiLoader: vi.fn((params) => { - jitiLoaderCalls.push({ - modulePath: params.modulePath, - jitiFilename: params.jitiFilename, - }); - return vi.fn(() => ({ - default: { - id: "demo", - register() {}, - }, - })); - }), - }; - }); - - const { loadOpenClawPlugins } = await importFreshModule( - import.meta.url, - "./loader.js?scope=jiti-filename", - ); - - const pluginRoot = writeBundledPluginFixture("demo"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = pluginRoot; - - loadOpenClawPlugins({ - cache: false, - workspaceDir: pluginRoot, - onlyPluginIds: ["demo"], - config: { - plugins: { - entries: { - demo: { - enabled: true, - }, - }, - }, - }, - }); - - expect(jitiLoaderCalls).toEqual([]); - }); -}); diff --git a/src/plugins/loader.native-module-loader.test.ts b/src/plugins/loader.native-module-loader.test.ts new file mode 100644 index 00000000000..02bdbab312b --- /dev/null +++ b/src/plugins/loader.native-module-loader.test.ts @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const tempDirs: string[] = []; + +function makeTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-loader-")); + tempDirs.push(dir); + return dir; +} + +function writeBundledPluginFixture(id: string) { + const pluginRoot = makeTempDir(); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "index.cjs"), + `module.exports = { id: ${JSON.stringify(id)}, register() {} };`, + "utf-8", + ); + return pluginRoot; +} + +function writePackagedPluginFixture(id: string) { + const pluginRoot = makeTempDir(); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: id, + type: "commonjs", + openclaw: { + extensions: ["./index.cjs"], + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "index.cjs"), + `module.exports = { id: ${JSON.stringify(id)}, register() {} };`, + "utf-8", + ); + return pluginRoot; +} + +afterEach(() => { + vi.resetModules(); + vi.doUnmock("./plugin-module-loader-cache.js"); + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function mockSourceLoaderCalls() { + const sourceLoaderCalls: Array<{ modulePath: string; loaderFilename?: string }> = []; + vi.doMock("./plugin-module-loader-cache.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedPluginSourceModuleLoader: vi.fn((params) => { + sourceLoaderCalls.push({ + modulePath: params.modulePath, + loaderFilename: params.loaderFilename, + }); + return vi.fn(() => ({ + default: { + id: "source-fallback", + register() {}, + }, + })); + }), + }; + }); + return sourceLoaderCalls; +} + +describe("createPluginModuleLoader", () => { + it("loads bundled JavaScript without creating a module loader", async () => { + const sourceLoaderCalls = mockSourceLoaderCalls(); + + const { loadOpenClawPlugins } = await importFreshModule( + import.meta.url, + "./loader.js?scope=native-module-loader", + ); + + const pluginRoot = writeBundledPluginFixture("demo"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = pluginRoot; + + loadOpenClawPlugins({ + cache: false, + workspaceDir: pluginRoot, + onlyPluginIds: ["demo"], + config: { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + }, + }, + }); + + expect(sourceLoaderCalls).toEqual([]); + }); + + it("loads packaged JavaScript without creating a module loader", async () => { + const sourceLoaderCalls = mockSourceLoaderCalls(); + + const { loadOpenClawPlugins } = await importFreshModule( + import.meta.url, + "./loader.js?scope=packaged-native-module-loader", + ); + + const pluginRoot = writePackagedPluginFixture("npm-demo"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = makeTempDir(); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + load: { + paths: [pluginRoot], + }, + allow: ["npm-demo"], + entries: { + "npm-demo": { + enabled: true, + }, + }, + }, + }, + }); + + expect(registry.plugins.find((plugin) => plugin.id === "npm-demo")?.status).toBe("loaded"); + expect(sourceLoaderCalls).toEqual([]); + }); +}); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 1c3c1247560..3f1e5068d92 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -56,7 +56,6 @@ import { listPluginInteractiveHandlers, restorePluginInteractiveHandlers, } from "./interactive-registry.js"; -import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import { PluginLoaderCacheState } from "./loader-cache-state.js"; import { channelPluginIdBelongsToManifest, @@ -104,6 +103,10 @@ import { import { unwrapDefaultModuleExport } from "./module-export.js"; import { tryNativeRequireJavaScriptModule } from "./native-module-require.js"; import { withProfile } from "./plugin-load-profile.js"; +import { + getCachedPluginSourceModuleLoader, + type PluginModuleLoaderCache, +} from "./plugin-module-loader-cache.js"; import { createPluginIdScopeSet, hasExplicitPluginIdScope, @@ -135,7 +138,7 @@ import { resolvePluginSdkAliasFile, resolvePluginRuntimeModulePath, resolvePluginSdkScopedAliasMap, - shouldPreferNativeJiti, + shouldPreferNativeModuleLoad, } from "./sdk-alias.js"; import { hasKind, kindsEqual } from "./slots.js"; import type { @@ -457,13 +460,13 @@ function runPluginRegisterSync( } function createPluginModuleLoader(options: Pick) { - const jitiLoaders: PluginJitiLoaderCache = new Map(); - const loadWithJiti = (modulePath: string) => { - return getCachedPluginJitiLoader({ - cache: jitiLoaders, + const moduleLoaders: PluginModuleLoaderCache = new Map(); + const loadSourceModule = (modulePath: string) => { + return getCachedPluginSourceModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, - jitiFilename: modulePath, + loaderFilename: modulePath, aliasMap: buildPluginLoaderAliasMap( modulePath, process.argv[1], @@ -471,11 +474,10 @@ function createPluginModuleLoader(options: Pick { - if (shouldPreferNativeJiti(modulePath)) { + if (shouldPreferNativeModuleLoad(modulePath)) { const native = tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true }); if (native.ok) { return native.moduleExport; @@ -484,7 +486,7 @@ function createPluginModuleLoader(options: Pick { vi.doUnmock("jiti"); }); -async function loadCachedPluginJitiLoader(scope: string) { +async function loadCachedPluginModuleLoader(scope: string) { const createJiti = vi.fn((filename: string, options?: Record) => Object.assign(vi.fn(), { filename, @@ -18,17 +18,17 @@ async function loadCachedPluginJitiLoader(scope: string) { createJiti, })); - const { getCachedPluginJitiLoader } = await importFreshModule< - typeof import("./jiti-loader-cache.js") - >(import.meta.url, `./jiti-loader-cache.js?scope=${scope}`); + const { getCachedPluginModuleLoader } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, `./plugin-module-loader-cache.js?scope=${scope}`); - return { createJiti, getCachedPluginJitiLoader }; + return { createJiti, getCachedPluginModuleLoader }; } -describe("getCachedPluginJitiLoader", () => { +describe("getCachedPluginModuleLoader", () => { it("reuses cached loaders for the same module config and filename", async () => { - const { createJiti, getCachedPluginJitiLoader } = - await loadCachedPluginJitiLoader("cached-loader"); + const { createJiti, getCachedPluginModuleLoader } = + await loadCachedPluginModuleLoader("cached-loader"); const cache = new Map(); const params = { @@ -36,11 +36,11 @@ describe("getCachedPluginJitiLoader", () => { modulePath: "/repo/extensions/demo/index.ts", importerUrl: "file:///repo/src/plugins/setup-registry.ts", argvEntry: "/repo/openclaw.mjs", - jitiFilename: "file:///repo/src/plugins/source-loader.ts", + loaderFilename: "file:///repo/src/plugins/source-loader.ts", } as const; - const first = getCachedPluginJitiLoader(params); - const second = getCachedPluginJitiLoader(params); + const first = getCachedPluginModuleLoader(params); + const second = getCachedPluginModuleLoader(params); expect(second).toBe(first); first("/repo/extensions/demo/index.ts"); @@ -48,26 +48,26 @@ describe("getCachedPluginJitiLoader", () => { expect(cache.size).toBe(1); }); - it("keeps loader caches scoped by jiti filename and dist preference", async () => { - const { createJiti, getCachedPluginJitiLoader } = - await loadCachedPluginJitiLoader("filename-scope"); + it("keeps loader caches scoped by loader filename and dist preference", async () => { + const { createJiti, getCachedPluginModuleLoader } = + await loadCachedPluginModuleLoader("filename-scope"); const cache = new Map(); - const first = getCachedPluginJitiLoader({ + const first = getCachedPluginModuleLoader({ cache, modulePath: "/repo/dist/extensions/demo/api.ts", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", argvEntry: "/repo/openclaw.mjs", preferBuiltDist: true, - jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", }); - const second = getCachedPluginJitiLoader({ + const second = getCachedPluginModuleLoader({ cache, modulePath: "/repo/dist/extensions/demo/api.ts", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", argvEntry: "/repo/openclaw.mjs", preferBuiltDist: true, - jitiFilename: "file:///repo/src/plugins/bundled-channel-config-metadata.ts", + loaderFilename: "file:///repo/src/plugins/bundled-channel-config-metadata.ts", }); expect(second).not.toBe(first); @@ -95,25 +95,26 @@ describe("getCachedPluginJitiLoader", () => { }); it("lets callers override alias maps and tryNative while keeping cache keys stable", async () => { - const { createJiti, getCachedPluginJitiLoader } = await loadCachedPluginJitiLoader("overrides"); + const { createJiti, getCachedPluginModuleLoader } = + await loadCachedPluginModuleLoader("overrides"); const cache = new Map(); - const first = getCachedPluginJitiLoader({ + const first = getCachedPluginModuleLoader({ cache, modulePath: "/repo/extensions/demo/index.ts", importerUrl: "file:///repo/src/plugins/loader.ts", - jitiFilename: "file:///repo/src/plugins/loader.ts", + loaderFilename: "file:///repo/src/plugins/loader.ts", aliasMap: { alpha: "/repo/alpha.js", zeta: "/repo/zeta.js", }, tryNative: false, }); - const second = getCachedPluginJitiLoader({ + const second = getCachedPluginModuleLoader({ cache, modulePath: "/repo/extensions/demo/index.ts", importerUrl: "file:///repo/src/plugins/loader.ts", - jitiFilename: "file:///repo/src/plugins/loader.ts", + loaderFilename: "file:///repo/src/plugins/loader.ts", aliasMap: { zeta: "/repo/zeta.js", alpha: "/repo/alpha.js", @@ -137,26 +138,26 @@ describe("getCachedPluginJitiLoader", () => { }); it("lets callers intentionally share loaders behind a custom cache scope key", async () => { - const { createJiti, getCachedPluginJitiLoader } = - await loadCachedPluginJitiLoader("cache-scope-key"); + const { createJiti, getCachedPluginModuleLoader } = + await loadCachedPluginModuleLoader("cache-scope-key"); const cache = new Map(); - const first = getCachedPluginJitiLoader({ + const first = getCachedPluginModuleLoader({ cache, modulePath: "/repo/dist/extensions/demo-a/api.js", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", - jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", aliasMap: { demo: "/repo/demo-a.js", }, tryNative: true, cacheScopeKey: "bundled:native", }); - const second = getCachedPluginJitiLoader({ + const second = getCachedPluginModuleLoader({ cache, modulePath: "/repo/dist/extensions/demo-b/api.js", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", - jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", aliasMap: { demo: "/repo/demo-b.js", }, @@ -171,26 +172,26 @@ describe("getCachedPluginJitiLoader", () => { }); it("reuses pre-normalized alias options across module-scoped loader filenames", async () => { - const { createJiti, getCachedPluginJitiLoader } = - await loadCachedPluginJitiLoader("module-filename-aliases"); + const { createJiti, getCachedPluginModuleLoader } = + await loadCachedPluginModuleLoader("module-filename-aliases"); const cache = new Map(); - getCachedPluginJitiLoader({ + getCachedPluginModuleLoader({ cache, modulePath: "/repo/extensions/demo-a/index.ts", importerUrl: "file:///repo/src/plugins/loader.ts", - jitiFilename: "/repo/extensions/demo-a/index.ts", + loaderFilename: "/repo/extensions/demo-a/index.ts", aliasMap: { alpha: "/repo/alpha", beta: "alpha/sub", }, tryNative: false, }); - getCachedPluginJitiLoader({ + getCachedPluginModuleLoader({ cache, modulePath: "/repo/extensions/demo-b/index.ts", importerUrl: "file:///repo/src/plugins/loader.ts", - jitiFilename: "/repo/extensions/demo-b/index.ts", + loaderFilename: "/repo/extensions/demo-b/index.ts", aliasMap: { beta: "alpha/sub", alpha: "/repo/alpha", @@ -198,22 +199,22 @@ describe("getCachedPluginJitiLoader", () => { tryNative: false, }); - getCachedPluginJitiLoader({ + getCachedPluginModuleLoader({ cache, modulePath: "/repo/extensions/demo-a/index.ts", importerUrl: "file:///repo/src/plugins/loader.ts", - jitiFilename: "/repo/extensions/demo-a/index.ts", + loaderFilename: "/repo/extensions/demo-a/index.ts", aliasMap: { alpha: "/repo/alpha", beta: "alpha/sub", }, tryNative: false, })("/repo/extensions/demo-a/index.ts"); - getCachedPluginJitiLoader({ + getCachedPluginModuleLoader({ cache, modulePath: "/repo/extensions/demo-b/index.ts", importerUrl: "file:///repo/src/plugins/loader.ts", - jitiFilename: "/repo/extensions/demo-b/index.ts", + loaderFilename: "/repo/extensions/demo-b/index.ts", aliasMap: { beta: "alpha/sub", alpha: "/repo/alpha", @@ -232,9 +233,9 @@ describe("getCachedPluginJitiLoader", () => { expect((firstAlias as Record)[marker]).toBe(true); }); - it("serves compiled .js targets from native require without invoking the jiti loader", async () => { - const jitiLoader = vi.fn(); - const createJiti = vi.fn(() => jitiLoader); + it("serves compiled .js targets from native require without invoking the module loader", async () => { + const fromSourceTransformer = vi.fn(); + const createJiti = vi.fn(() => fromSourceTransformer); vi.doMock("jiti", () => ({ createJiti })); const nativeStub = vi.fn((target: string) => ({ ok: true as const, @@ -245,16 +246,16 @@ describe("getCachedPluginJitiLoader", () => { p.endsWith(".js") || p.endsWith(".mjs") || p.endsWith(".cjs"), tryNativeRequireJavaScriptModule: nativeStub, })); - const { getCachedPluginJitiLoader } = await importFreshModule< - typeof import("./jiti-loader-cache.js") - >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fastpath"); + const { getCachedPluginModuleLoader } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-fastpath"); const cache = new Map(); - const loader = getCachedPluginJitiLoader({ + const loader = getCachedPluginModuleLoader({ cache, modulePath: "/repo/dist/extensions/demo/api.js", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", - jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", }); const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string }; @@ -262,57 +263,57 @@ describe("getCachedPluginJitiLoader", () => { // Jiti should not be constructed or invoked for .js targets that // `tryNativeRequireJavaScriptModule` resolves. expect(createJiti).not.toHaveBeenCalled(); - expect(jitiLoader).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, }); }); - it("falls back to jiti when the native-require helper declines", async () => { - const jitiLoader = vi.fn(() => ({ fromJiti: true })); - const createJiti = vi.fn(() => jitiLoader); + 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 }), })); - const { getCachedPluginJitiLoader } = await importFreshModule< - typeof import("./jiti-loader-cache.js") - >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fallback"); + const { getCachedPluginModuleLoader } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-fallback"); const cache = new Map(); - const loader = getCachedPluginJitiLoader({ + const loader = getCachedPluginModuleLoader({ cache, modulePath: "/repo/dist/extensions/demo/api.js", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", - jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", }); - const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean }; - expect(result.fromJiti).toBe(true); - expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); + const result = loader("/repo/dist/extensions/demo/api.js") as { fromSourceTransform: boolean }; + expect(result.fromSourceTransform).toBe(true); + expect(fromSourceTransformer).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); }); - it("normalizes Windows absolute paths before creating and calling jiti", async () => { + it("normalizes Windows absolute paths before creating and calling the source transformer", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const jitiLoader = vi.fn(() => ({ fromJiti: true })); - const createJiti = vi.fn(() => jitiLoader); + 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 }), })); - const { getCachedPluginJitiLoader } = await importFreshModule< - typeof import("./jiti-loader-cache.js") - >(import.meta.url, "./jiti-loader-cache.js?scope=windows-jiti-paths"); + const { getCachedPluginModuleLoader } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=windows-jiti-paths"); const cache = new Map(); - const loader = getCachedPluginJitiLoader({ + const loader = getCachedPluginModuleLoader({ cache, modulePath: "C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\api.js", importerUrl: "file:///C:/Users/alice/openclaw/dist/src/plugins/public-surface-loader.js", - jitiFilename: "C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\api.js", + loaderFilename: "C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\api.js", tryNative: true, }); @@ -322,62 +323,62 @@ describe("getCachedPluginJitiLoader", () => { "file:///C:/Users/alice/openclaw/dist/extensions/feishu/api.js", expect.objectContaining({ tryNative: true }), ); - expect(jitiLoader).toHaveBeenCalledWith( + expect(fromSourceTransformer).toHaveBeenCalledWith( "file:///C:/Users/alice/openclaw/dist/extensions/feishu/api.js", ); }); it("skips the native-require fast path when tryNative is explicitly false", async () => { - const jitiLoader = vi.fn(() => ({ fromJiti: true })); - const createJiti = vi.fn(() => jitiLoader); + 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, tryNativeRequireJavaScriptModule: nativeStub, })); - const { getCachedPluginJitiLoader } = await importFreshModule< - typeof import("./jiti-loader-cache.js") - >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-opt-out"); + const { getCachedPluginModuleLoader } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-opt-out"); const cache = new Map(); - const loader = getCachedPluginJitiLoader({ + const loader = getCachedPluginModuleLoader({ cache, modulePath: "/repo/dist/extensions/demo/api.js", importerUrl: "file:///repo/src/plugins/bundled-capability-runtime.ts", - jitiFilename: "file:///repo/src/plugins/bundled-capability-runtime.ts", + loaderFilename: "file:///repo/src/plugins/bundled-capability-runtime.ts", aliasMap: { "openclaw/plugin-sdk": "/repo/shim.js" }, tryNative: false, }); - const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean }; - expect(result.fromJiti).toBe(true); - // With tryNative: false the wrapper must route every target through jiti + const result = loader("/repo/dist/extensions/demo/api.js") as { fromSourceTransform: boolean }; + expect(result.fromSourceTransform).toBe(true); + // With tryNative: false the wrapper must route every target through the source transformer // so its alias rewrites still apply; native require must not be consulted. expect(nativeStub).not.toHaveBeenCalled(); - expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); + expect(fromSourceTransformer).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); }); it("normalizes Windows absolute paths when native loading is disabled", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const jitiLoader = vi.fn(() => ({ fromJiti: true })); - const createJiti = vi.fn(() => jitiLoader); + 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, tryNativeRequireJavaScriptModule: nativeStub, })); - const { getCachedPluginJitiLoader } = await importFreshModule< - typeof import("./jiti-loader-cache.js") - >(import.meta.url, "./jiti-loader-cache.js?scope=windows-jiti-no-native"); + const { getCachedPluginModuleLoader } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=windows-jiti-no-native"); const cache = new Map(); - const loader = getCachedPluginJitiLoader({ + const loader = getCachedPluginModuleLoader({ cache, modulePath: "C:\\Users\\alice\\openclaw\\extensions\\feishu\\api.ts", importerUrl: "file:///C:/Users/alice/openclaw/src/plugins/loader.ts", - jitiFilename: "C:\\Users\\alice\\openclaw\\extensions\\feishu\\api.ts", + loaderFilename: "C:\\Users\\alice\\openclaw\\extensions\\feishu\\api.ts", tryNative: false, }); @@ -388,33 +389,37 @@ describe("getCachedPluginJitiLoader", () => { "file:///C:/Users/alice/openclaw/extensions/feishu/api.ts", expect.objectContaining({ tryNative: false }), ); - expect(jitiLoader).toHaveBeenCalledWith( + expect(fromSourceTransformer).toHaveBeenCalledWith( "file:///C:/Users/alice/openclaw/extensions/feishu/api.ts", ); }); - it("forwards extra loader arguments through to the jiti fallback", async () => { - const jitiLoader = vi.fn(() => ({ fromJiti: true })); - const createJiti = vi.fn(() => jitiLoader); + 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 }), })); - const { getCachedPluginJitiLoader } = await importFreshModule< - typeof import("./jiti-loader-cache.js") - >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-rest-args"); + const { getCachedPluginModuleLoader } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-rest-args"); const cache = new Map(); - const loader = getCachedPluginJitiLoader({ + const loader = getCachedPluginModuleLoader({ cache, modulePath: "/repo/dist/extensions/demo/api.js", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", - jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", }); const loose = loader as unknown as (t: string, ...a: unknown[]) => unknown; loose("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42); - expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42); + expect(fromSourceTransformer).toHaveBeenCalledWith( + "/repo/dist/extensions/demo/api.js", + { hint: "x" }, + 42, + ); }); }); diff --git a/src/plugins/jiti-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts similarity index 69% rename from src/plugins/jiti-loader-cache.ts rename to src/plugins/plugin-module-loader-cache.ts index cd037a4087f..13c7d6d58ad 100644 --- a/src/plugins/jiti-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -3,31 +3,31 @@ import { toSafeImportPath } from "../shared/import-specifier.js"; import { tryNativeRequireJavaScriptModule } from "./native-module-require.js"; import { buildPluginLoaderJitiOptions, - createPluginLoaderJitiCacheKey, - resolvePluginLoaderJitiConfig, + createPluginLoaderModuleCacheKey, + resolvePluginLoaderModuleConfig, type PluginSdkResolutionPreference, } from "./sdk-alias.js"; -export type PluginJitiLoader = ReturnType; -export type PluginJitiLoaderFactory = typeof createJiti; -export type PluginJitiLoaderCache = Map; +export type PluginModuleLoader = ReturnType; +export type PluginModuleLoaderFactory = typeof createJiti; +export type PluginModuleLoaderCache = Map; -export function getCachedPluginJitiLoader(params: { - cache: PluginJitiLoaderCache; +export function getCachedPluginModuleLoader(params: { + cache: PluginModuleLoaderCache; modulePath: string; importerUrl: string; argvEntry?: string; preferBuiltDist?: boolean; - jitiFilename?: string; - createLoader?: PluginJitiLoaderFactory; + loaderFilename?: string; + createLoader?: PluginModuleLoaderFactory; aliasMap?: Record; tryNative?: boolean; pluginSdkResolution?: PluginSdkResolutionPreference; cacheScopeKey?: string; -}): PluginJitiLoader { - const jitiFilename = toSafeImportPath(params.jitiFilename ?? params.modulePath); +}): PluginModuleLoader { + const loaderFilename = toSafeImportPath(params.loaderFilename ?? params.modulePath); if (params.cacheScopeKey) { - const scopedCacheKey = `${jitiFilename}::${params.cacheScopeKey}`; + const scopedCacheKey = `${loaderFilename}::${params.cacheScopeKey}`; const cached = params.cache.get(scopedCacheKey); if (cached) { return cached; @@ -37,7 +37,7 @@ export function getCachedPluginJitiLoader(params: { const hasTryNativeOverride = typeof params.tryNative === "boolean"; const defaultConfig = hasAliasOverride || hasTryNativeOverride - ? resolvePluginLoaderJitiConfig({ + ? resolvePluginLoaderModuleConfig({ modulePath: params.modulePath, argv1: params.argvEntry ?? process.argv[1], moduleUrl: params.importerUrl, @@ -57,7 +57,7 @@ export function getCachedPluginJitiLoader(params: { aliasMap: params.aliasMap ?? defaultConfig.aliasMap, cacheKey: canReuseDefaultCacheKey ? defaultConfig.cacheKey : undefined, } - : resolvePluginLoaderJitiConfig({ + : resolvePluginLoaderModuleConfig({ modulePath: params.modulePath, argv1: params.argvEntry ?? process.argv[1], moduleUrl: params.importerUrl, @@ -67,25 +67,25 @@ export function getCachedPluginJitiLoader(params: { const { tryNative, aliasMap } = resolved; const cacheKey = resolved.cacheKey ?? - createPluginLoaderJitiCacheKey({ + createPluginLoaderModuleCacheKey({ tryNative, aliasMap, }); - const scopedCacheKey = `${jitiFilename}::${params.cacheScopeKey ?? cacheKey}`; + const scopedCacheKey = `${loaderFilename}::${params.cacheScopeKey ?? cacheKey}`; const cached = params.cache.get(scopedCacheKey); if (cached) { return cached; } - let loadWithJiti: PluginJitiLoader | undefined; - const getLoadWithJiti = (): PluginJitiLoader => { - if (loadWithJiti) { - return loadWithJiti; + let loadWithSourceTransform: PluginModuleLoader | undefined; + const getLoadWithSourceTransform = (): PluginModuleLoader => { + if (loadWithSourceTransform) { + return loadWithSourceTransform; } - const jitiLoader = (params.createLoader ?? createJiti)(jitiFilename, { + const jitiLoader = (params.createLoader ?? createJiti)(loaderFilename, { ...buildPluginLoaderJitiOptions(aliasMap), tryNative, }); - loadWithJiti = new Proxy(jitiLoader, { + loadWithSourceTransform = new Proxy(jitiLoader, { apply(target, thisArg, argArray) { const [first, ...rest] = argArray as [unknown, ...unknown[]]; if (typeof first === "string") { @@ -97,7 +97,7 @@ export function getCachedPluginJitiLoader(params: { return Reflect.apply(target, thisArg, argArray as never) as never; }, }); - return loadWithJiti; + return loadWithSourceTransform; }; // When the caller has explicitly opted out of native loading (for example // `bundled-capability-runtime` in Vitest+dist mode, which depends on @@ -105,10 +105,10 @@ export function getCachedPluginJitiLoader(params: { // target through jiti so those alias rewrites still apply. if (!tryNative) { const loader = ((target: string, ...rest: unknown[]) => - (getLoadWithJiti() as (t: string, ...a: unknown[]) => unknown)( + (getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)( target, ...rest, - )) as PluginJitiLoader; + )) as PluginModuleLoader; params.cache.set(scopedCacheKey, loader); return loader; } @@ -124,8 +124,20 @@ export function getCachedPluginJitiLoader(params: { if (native.ok) { return native.moduleExport; } - return (getLoadWithJiti() as (t: string, ...a: unknown[]) => unknown)(target, ...rest); - }) as PluginJitiLoader; + return (getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)( + target, + ...rest, + ); + }) as PluginModuleLoader; params.cache.set(scopedCacheKey, loader); return loader; } + +export function getCachedPluginSourceModuleLoader( + params: Omit[0], "tryNative">, +): PluginModuleLoader { + return getCachedPluginModuleLoader({ + ...params, + tryNative: false, + }); +} diff --git a/src/plugins/plugin-peer-link.ts b/src/plugins/plugin-peer-link.ts new file mode 100644 index 00000000000..ade9fd27a97 --- /dev/null +++ b/src/plugins/plugin-peer-link.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; + +type PluginPeerLinkLogger = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +/** + * Symlink the host openclaw package for plugins that declare it as a peer. + * Plugin package managers still own third-party dependencies; this only wires + * the host SDK package into the plugin-local Node graph. + */ +export async function linkOpenClawPeerDependencies(params: { + installedDir: string; + peerDependencies: Record; + logger: PluginPeerLinkLogger; +}): Promise { + const peers = Object.keys(params.peerDependencies).filter((name) => name === "openclaw"); + if (peers.length === 0) { + return; + } + + const hostRoot = resolveOpenClawPackageRootSync({ + argv1: process.argv[1], + moduleUrl: import.meta.url, + cwd: process.cwd(), + }); + if (!hostRoot) { + params.logger.warn?.( + "Could not locate openclaw package root to symlink peerDependencies; plugin may fail to resolve openclaw at runtime.", + ); + return; + } + + const nodeModulesDir = path.join(params.installedDir, "node_modules"); + await fs.mkdir(nodeModulesDir, { recursive: true }); + + for (const peerName of peers) { + const linkPath = path.join(nodeModulesDir, peerName); + + try { + await fs.rm(linkPath, { recursive: true, force: true }); + await fs.symlink(hostRoot, linkPath, "junction"); + params.logger.info?.(`Linked peerDependency "${peerName}" -> ${hostRoot}`); + } catch (err) { + params.logger.warn?.(`Failed to symlink peerDependency "${peerName}": ${String(err)}`); + } + } +} diff --git a/src/plugins/public-surface-loader.ts b/src/plugins/public-surface-loader.ts index 798fa922893..082e64a8d99 100644 --- a/src/plugins/public-surface-loader.ts +++ b/src/plugins/public-surface-loader.ts @@ -5,9 +5,12 @@ import { fileURLToPath } from "node:url"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { sameFileIdentity } from "../infra/file-identity.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; -import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; +import { + getCachedPluginModuleLoader, + type PluginModuleLoaderCache, +} from "./plugin-module-loader-cache.js"; import { resolveBundledPluginPublicSurfacePath } from "./public-surface-runtime.js"; -import { resolvePluginLoaderJitiTryNative, resolveLoaderPackageRoot } from "./sdk-alias.js"; +import { resolvePluginLoaderTryNative, resolveLoaderPackageRoot } from "./sdk-alias.js"; const OPENCLAW_PACKAGE_ROOT = resolveLoaderPackageRoot({ @@ -23,7 +26,7 @@ const publicSurfaceLocations = new Map< boundaryRoot: string; } | null >(); -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const moduleLoaders: PluginModuleLoaderCache = new Map(); function isSourceArtifactPath(modulePath: string): boolean { switch (path.extname(modulePath).toLowerCase()) { @@ -88,22 +91,22 @@ function resolvePublicSurfaceLocation(params: { return resolved; } -function getJiti(modulePath: string) { - return getCachedPluginJitiLoader({ - cache: jitiLoaders, +function getModuleLoader(modulePath: string) { + return getCachedPluginModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, preferBuiltDist: true, - jitiFilename: import.meta.url, + loaderFilename: import.meta.url, }); } function loadPublicSurfaceModule(modulePath: string): unknown { - const tryNative = resolvePluginLoaderJitiTryNative(modulePath, { preferBuiltDist: true }); + const tryNative = resolvePluginLoaderTryNative(modulePath, { preferBuiltDist: true }); if (canUseSourceArtifactRequire({ modulePath, tryNative })) { return sourceArtifactRequire(modulePath); } - return getJiti(modulePath)(modulePath); + return getModuleLoader(modulePath)(modulePath); } // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Dynamic public artifact loaders use caller-supplied module surface types. @@ -170,5 +173,5 @@ export function resolveBundledPluginPublicArtifactPath(params: { export function resetBundledPluginPublicArtifactLoaderForTest(): void { loadedPublicSurfaceModules.clear(); publicSurfaceLocations.clear(); - jitiLoaders.clear(); + moduleLoaders.clear(); } diff --git a/src/plugins/runtime-plugin-boundary.whatsapp.test.ts b/src/plugins/runtime-plugin-boundary.whatsapp.test.ts index 2e43498a134..9b65bc594fe 100644 --- a/src/plugins/runtime-plugin-boundary.whatsapp.test.ts +++ b/src/plugins/runtime-plugin-boundary.whatsapp.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it } from "vitest"; import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs"; -import type { PluginJitiLoaderCache } from "./jiti-loader-cache.js"; +import type { PluginModuleLoaderCache } from "./plugin-module-loader-cache.js"; import { loadPluginBoundaryModule } from "./runtime/runtime-plugin-boundary.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; @@ -122,7 +122,7 @@ function createExternalTypeScriptRuntimePackageFixture() { } function loadWhatsAppBoundaryModules(runtimePluginDir: string) { - const loaders: PluginJitiLoaderCache = new Map(); + const loaders: PluginModuleLoaderCache = new Map(); return { light: loadPluginBoundaryModule( path.join(runtimePluginDir, "light-runtime-api.js"), @@ -165,7 +165,7 @@ describe("runtime plugin boundary whatsapp seam", () => { const rootDir = makeTrackedTempDir("openclaw-bundled-boundary-ts", tempDirs); const modulePath = path.join(rootDir, "runtime-api.ts"); writeRuntimeFixtureText(rootDir, "runtime-api.ts", "export const ok = true;\n"); - const loaders: PluginJitiLoaderCache = new Map(); + const loaders: PluginModuleLoaderCache = new Map(); expect(() => loadPluginBoundaryModule<{ ok: boolean }>(modulePath, loaders, { origin: "bundled" }), @@ -173,9 +173,9 @@ describe("runtime plugin boundary whatsapp seam", () => { expect(loaders.size).toBe(0); }); - it("keeps the Jiti TypeScript package fallback available for non-bundled plugins", () => { + it("keeps the TypeScript source package fallback available for non-bundled plugins", () => { const modulePath = createExternalTypeScriptRuntimePackageFixture(); - const loaders: PluginJitiLoaderCache = new Map(); + const loaders: PluginModuleLoaderCache = new Map(); expect( loadPluginBoundaryModule<{ ok: boolean; loadedVia: string }>(modulePath, loaders, { diff --git a/src/plugins/runtime/runtime-plugin-boundary.ts b/src/plugins/runtime/runtime-plugin-boundary.ts index 0eee570f6aa..aa8a5f533cd 100644 --- a/src/plugins/runtime/runtime-plugin-boundary.ts +++ b/src/plugins/runtime/runtime-plugin-boundary.ts @@ -1,12 +1,15 @@ import fs from "node:fs"; import path from "node:path"; import { getRuntimeConfig } from "../../config/config.js"; -import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "../jiti-loader-cache.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { isJavaScriptModulePath, tryNativeRequireJavaScriptModule, } from "../native-module-require.js"; +import { + getCachedPluginSourceModuleLoader, + type PluginModuleLoaderCache, +} from "../plugin-module-loader-cache.js"; import type { PluginOrigin } from "../plugin-origin.types.js"; type PluginRuntimeRecord = { @@ -109,20 +112,19 @@ export function resolvePluginRuntimeModulePath( return null; } -function getPluginBoundarySourceLoader(modulePath: string, loaders: PluginJitiLoaderCache) { - return getCachedPluginJitiLoader({ +function getPluginBoundarySourceLoader(modulePath: string, loaders: PluginModuleLoaderCache) { + return getCachedPluginSourceModuleLoader({ cache: loaders, modulePath, importerUrl: import.meta.url, - jitiFilename: import.meta.url, - tryNative: false, + loaderFilename: import.meta.url, }); } // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Dynamic plugin boundary loaders use caller-supplied module types. export function loadPluginBoundaryModule( modulePath: string, - loaders: PluginJitiLoaderCache, + loaders: PluginModuleLoaderCache, options: { origin?: PluginOrigin } = {}, ): TModule { if (isJavaScriptModulePath(modulePath)) { diff --git a/src/plugins/runtime/runtime-web-channel-plugin.ts b/src/plugins/runtime/runtime-web-channel-plugin.ts index 1e2f1354757..2c9351b4e8b 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.ts @@ -8,7 +8,7 @@ import { optimizeImageToJpeg as optimizeImageToJpegImpl, } from "../../media/web-media.js"; import type { PollInput } from "../../polls.js"; -import type { PluginJitiLoaderCache } from "../jiti-loader-cache.js"; +import type { PluginModuleLoaderCache } from "../plugin-module-loader-cache.js"; import type { PluginOrigin } from "../plugin-origin.types.js"; import { loadPluginBoundaryModule, @@ -109,7 +109,7 @@ let cachedHeavyModule: WebChannelHeavyRuntimeModule | null = null; let cachedLightModulePath: string | null = null; let cachedLightModule: WebChannelLightRuntimeModule | null = null; -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const moduleLoaders: PluginModuleLoaderCache = new Map(); function resolveWebChannelPluginRecord(): WebChannelPluginRecord { return resolvePluginRuntimeRecordByEntryBaseNames(["light-runtime-api", "runtime-api"], () => { @@ -135,7 +135,7 @@ function resolveWebChannelRuntimeModulePath( function loadCurrentHeavyModuleSync(): WebChannelHeavyRuntimeModule { const record = resolveWebChannelPluginRecord(); const modulePath = resolveWebChannelRuntimeModulePath(record, "runtime-api"); - return loadPluginBoundaryModule(modulePath, jitiLoaders, { + return loadPluginBoundaryModule(modulePath, moduleLoaders, { origin: record.origin, }); } @@ -146,7 +146,7 @@ function loadWebChannelLightModule(): WebChannelLightRuntimeModule { if (cachedLightModule && cachedLightModulePath === modulePath) { return cachedLightModule; } - const loaded = loadPluginBoundaryModule(modulePath, jitiLoaders, { + const loaded = loadPluginBoundaryModule(modulePath, moduleLoaders, { origin: record.origin, }); cachedLightModulePath = modulePath; @@ -160,7 +160,7 @@ async function loadWebChannelHeavyModule(): Promise(modulePath, jitiLoaders, { + const loaded = loadPluginBoundaryModule(modulePath, moduleLoaders, { origin: record.origin, }); cachedHeavyModulePath = modulePath; diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index c72ab8f19af..e769a30fe8d 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -10,18 +10,18 @@ import { afterAll, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; import { buildPluginLoaderAliasMap, - createPluginLoaderJitiCacheKey, + createPluginLoaderModuleCacheKey, buildPluginLoaderJitiOptions, isBundledPluginExtensionPath, listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, normalizeJitiAliasTargetPath, - resolvePluginLoaderJitiConfig, - resolvePluginLoaderJitiTryNative, + resolvePluginLoaderModuleConfig, + resolvePluginLoaderTryNative, resolveExtensionApiAlias, resolvePluginRuntimeModulePath, resolvePluginSdkAliasFile, - shouldPreferNativeJiti, + shouldPreferNativeModuleLoad, } from "./sdk-alias.js"; import { cleanupTrackedTempDirs, @@ -912,7 +912,7 @@ describe("plugin sdk alias helpers", () => { }); }); - it("configures the plugin loader jiti boundary to prefer native dist modules", () => { + it("configures the plugin loader native-first boundary to prefer native dist modules", () => { const options = buildPluginLoaderJitiOptions({}); expect(options.tryNative).toBe(true); @@ -922,14 +922,16 @@ describe("plugin sdk alias helpers", () => { expect("alias" in options).toBe(false); }); - it("uses transpiled Jiti loads for source TypeScript plugin entries", () => { - expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true); + it("uses transpiled module loads for source TypeScript plugin entries", () => { + expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(true); expect( - shouldPreferNativeJiti(`/repo/${bundledPluginFile("discord", "src/channel.runtime.ts")}`), + shouldPreferNativeModuleLoad( + `/repo/${bundledPluginFile("discord", "src/channel.runtime.ts")}`, + ), ).toBe(false); }); - it("disables native Jiti loads under Bun even for built JavaScript entries", () => { + it("disables native module loads under Bun even for built JavaScript entries", () => { const originalVersions = process.versions; Object.defineProperty(process, "versions", { configurable: true, @@ -940,10 +942,10 @@ describe("plugin sdk alias helpers", () => { }); try { - expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(false); - expect(shouldPreferNativeJiti(`/repo/${bundledDistPluginFile("browser", "index.js")}`)).toBe( - false, - ); + expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(false); + expect( + shouldPreferNativeModuleLoad(`/repo/${bundledDistPluginFile("browser", "index.js")}`), + ).toBe(false); } finally { Object.defineProperty(process, "versions", { configurable: true, @@ -952,7 +954,7 @@ describe("plugin sdk alias helpers", () => { } }); - it("enables native Jiti loads on Windows for built JavaScript entries", () => { + it("enables native module loads on Windows for built JavaScript entries", () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, @@ -960,10 +962,10 @@ describe("plugin sdk alias helpers", () => { }); try { - expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true); - expect(shouldPreferNativeJiti(`/repo/${bundledDistPluginFile("browser", "index.js")}`)).toBe( - true, - ); + expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(true); + expect( + shouldPreferNativeModuleLoad(`/repo/${bundledDistPluginFile("browser", "index.js")}`), + ).toBe(true); } finally { Object.defineProperty(process, "platform", { configurable: true, @@ -972,7 +974,7 @@ describe("plugin sdk alias helpers", () => { } }); - it("keeps plugin loader dist shortcuts on native Jiti on Windows for JS entries", () => { + it("keeps plugin loader dist shortcuts on native module loading on Windows for JS entries", () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, @@ -981,12 +983,12 @@ describe("plugin sdk alias helpers", () => { try { expect( - resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, { + resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, { preferBuiltDist: true, }), ).toBe(true); expect( - resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, { + resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, { preferBuiltDist: true, }), ).toBe(false); @@ -998,31 +1000,31 @@ describe("plugin sdk alias helpers", () => { } }); - it("prefers native jiti for bundled plugin dist .js modules, keeps .ts on aliased path", () => { + it("prefers native module loading for bundled plugin dist .js modules, keeps .ts on aliased path", () => { // Built .js/.mjs/.cjs files under dist/extensions/ should now delegate - // to shouldPreferNativeJiti() — which returns true on Node for + // to shouldPreferNativeModuleLoad() — which returns true on Node for // compiled artifacts, avoiding the slow jiti transform path. expect( - resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, { + resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, { preferBuiltDist: true, }), ).toBe(true); // TypeScript source files still need jiti's transform pipeline. expect( - resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, { + resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, { preferBuiltDist: true, }), ).toBe(false); expect( - resolvePluginLoaderJitiTryNative("/repo/dist/plugins/runtime/index.js", { + resolvePluginLoaderTryNative("/repo/dist/plugins/runtime/index.js", { preferBuiltDist: true, }), ).toBe(true); }); - it("keeps plugin loader Jiti cache keys stable across alias insertion order", () => { + it("keeps plugin loader module cache keys stable across alias insertion order", () => { expect( - createPluginLoaderJitiCacheKey({ + createPluginLoaderModuleCacheKey({ tryNative: true, aliasMap: { zeta: "/repo/zeta.js", @@ -1030,7 +1032,7 @@ describe("plugin sdk alias helpers", () => { }, }), ).toBe( - createPluginLoaderJitiCacheKey({ + createPluginLoaderModuleCacheKey({ tryNative: true, aliasMap: { alpha: "/repo/alpha.js", @@ -1040,14 +1042,14 @@ describe("plugin sdk alias helpers", () => { ); }); - it("returns plugin loader Jiti config with stable cache keys", () => { - const first = resolvePluginLoaderJitiConfig({ + it("returns plugin loader module config with stable cache keys", () => { + const first = resolvePluginLoaderModuleConfig({ modulePath: `/repo/${bundledDistPluginFile("browser", "index.js")}`, argv1: "/repo/openclaw.mjs", moduleUrl: "file:///repo/src/plugins/public-surface-loader.ts", preferBuiltDist: true, }); - const second = resolvePluginLoaderJitiConfig({ + const second = resolvePluginLoaderModuleConfig({ modulePath: `/repo/${bundledDistPluginFile("browser", "index.js")}`, argv1: "/repo/openclaw.mjs", moduleUrl: "file:///repo/src/plugins/public-surface-loader.ts", @@ -1057,7 +1059,7 @@ describe("plugin sdk alias helpers", () => { expect(second).toBe(first); }); - it("scopes plugin loader Jiti config by plugin-sdk resolution", () => { + it("scopes plugin loader module config by plugin-sdk resolution", () => { const { fixture, sourceRootAlias, distRootAlias } = createPluginSdkAliasTargetFixture(); const sourcePluginEntry = writePluginEntry( fixture.root, @@ -1065,19 +1067,19 @@ describe("plugin sdk alias helpers", () => { ); const { auto, dist, distAgain } = withEnv({ NODE_ENV: undefined }, () => ({ - auto: resolvePluginLoaderJitiConfig({ + auto: resolvePluginLoaderModuleConfig({ modulePath: sourcePluginEntry, argv1: path.join(fixture.root, "openclaw.mjs"), moduleUrl: pathToFileURL(path.join(fixture.root, "src/plugins/loader.ts")).href, pluginSdkResolution: "auto", }), - dist: resolvePluginLoaderJitiConfig({ + dist: resolvePluginLoaderModuleConfig({ modulePath: sourcePluginEntry, argv1: path.join(fixture.root, "openclaw.mjs"), moduleUrl: pathToFileURL(path.join(fixture.root, "src/plugins/loader.ts")).href, pluginSdkResolution: "dist", }), - distAgain: resolvePluginLoaderJitiConfig({ + distAgain: resolvePluginLoaderModuleConfig({ modulePath: sourcePluginEntry, argv1: path.join(fixture.root, "openclaw.mjs"), moduleUrl: pathToFileURL(path.join(fixture.root, "src/plugins/loader.ts")).href, @@ -1116,7 +1118,7 @@ describe("plugin sdk alias helpers", () => { ).toBe(false); }); - it("normalizes Windows alias targets before handing them to Jiti", () => { + it("normalizes Windows alias targets before handing them to the source transformer", () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, @@ -1135,14 +1137,14 @@ describe("plugin sdk alias helpers", () => { } }); - it("loads source runtime shims through the non-native Jiti boundary", async () => { + it("loads source runtime shims through the non-native module loading boundary", async () => { const copiedExtensionRoot = path.join(makeTempDir(), bundledPluginRoot("discord")); const copiedSourceDir = path.join(copiedExtensionRoot, "src"); const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk"); mkdirSafeDir(copiedSourceDir); mkdirSafeDir(copiedPluginSdkDir); - const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs"); - fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8"); + const sourceLoaderBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs"); + fs.writeFileSync(sourceLoaderBaseFile, "export {};\n", "utf-8"); fs.writeFileSync( path.join(copiedSourceDir, "channel.runtime.ts"), `import { resolveOutboundSendDep } from "@openclaw/plugin-sdk/outbound-send-deps"; @@ -1163,16 +1165,16 @@ export const syntheticRuntimeMarker = { "utf-8", ); const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts"); - const jitiBaseUrl = pathToFileURL(jitiBaseFile).href; + const sourceLoaderBaseUrl = pathToFileURL(sourceLoaderBaseFile).href; const createJiti = await getCreateJiti(); - const withoutAlias = createJiti(jitiBaseUrl, { + const withoutAlias = createJiti(sourceLoaderBaseUrl, { ...buildPluginLoaderJitiOptions({}), tryNative: false, }); expect(() => withoutAlias(copiedChannelRuntime)).toThrow(); - const withAlias = createJiti(jitiBaseUrl, { + const withAlias = createJiti(sourceLoaderBaseUrl, { ...buildPluginLoaderJitiOptions({ "openclaw/plugin-sdk/outbound-send-deps": copiedChannelRuntimeShim, "@openclaw/plugin-sdk/outbound-send-deps": copiedChannelRuntimeShim, @@ -1351,7 +1353,7 @@ describe("buildPluginLoaderAliasMap memoization", () => { }); describe("buildPluginLoaderJitiOptions", () => { - it("pre-normalizes and marks alias maps for Jiti", () => { + it("pre-normalizes and marks alias maps for source transforms", () => { const marker = Symbol.for("pathe:normalizedAlias"); const aliasMap = { "openclaw/plugin-sdk/core": "/repo/src/plugin-sdk/core.ts", @@ -1367,7 +1369,7 @@ describe("buildPluginLoaderJitiOptions", () => { expect(Object.prototype.propertyIsEnumerable.call(first, marker)).toBe(false); }); - it("applies Jiti alias-target normalization before caching", () => { + it("applies source-transform alias-target normalization before caching", () => { const aliasMap = { alpha: "/repo/alpha", beta: "alpha/sub", diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 26b5aba0d06..067e748801a 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -490,7 +490,7 @@ const JITI_ALIAS_ROOT_SENTINELS = new Set(["/", "\\", undefi // surfaces depend on them. const aliasMapCache = new Map>(); const normalizedJitiAliasMapCache = new Map>(); -const pluginLoaderJitiConfigCache = new Map< +const pluginLoaderModuleConfigCache = new Map< string, { tryNative: boolean; @@ -578,7 +578,7 @@ function buildPluginLoaderAliasMapCacheKey(params: { ].join("\0"); } -function buildPluginLoaderJitiConfigCacheKey(params: { +function buildPluginLoaderModuleConfigCacheKey(params: { modulePath: string; argv1?: string; moduleUrl: string; @@ -693,7 +693,7 @@ export function buildPluginLoaderJitiOptions(aliasMap: Record) { }; } -function supportsNativeJitiRuntime(): boolean { +function supportsNativeModuleRuntime(): boolean { const versions = process.versions as { bun?: string }; return typeof versions.bun !== "string"; } @@ -702,8 +702,8 @@ function isBundledPluginDistModulePath(modulePath: string): boolean { return modulePath.replace(/\\/g, "/").includes("/dist/extensions/"); } -export function shouldPreferNativeJiti(modulePath: string): boolean { - if (!supportsNativeJitiRuntime()) { +export function shouldPreferNativeModuleLoad(modulePath: string): boolean { + if (!supportsNativeModuleRuntime()) { return false; } switch (normalizeLowercaseStringOrEmpty(path.extname(modulePath))) { @@ -717,24 +717,24 @@ export function shouldPreferNativeJiti(modulePath: string): boolean { } } -export function resolvePluginLoaderJitiTryNative( +export function resolvePluginLoaderTryNative( modulePath: string, options?: { preferBuiltDist?: boolean; }, ): boolean { if (isBundledPluginDistModulePath(modulePath)) { - return shouldPreferNativeJiti(modulePath); + return shouldPreferNativeModuleLoad(modulePath); } return ( - shouldPreferNativeJiti(modulePath) || - (supportsNativeJitiRuntime() && + shouldPreferNativeModuleLoad(modulePath) || + (supportsNativeModuleRuntime() && options?.preferBuiltDist === true && modulePath.includes(`${path.sep}dist${path.sep}`)) ); } -export function createPluginLoaderJitiCacheKey(params: { +export function createPluginLoaderModuleCacheKey(params: { tryNative: boolean; aliasMap: Record; }): string { @@ -746,7 +746,7 @@ export function createPluginLoaderJitiCacheKey(params: { }); } -export function resolvePluginLoaderJitiConfig(params: { +export function resolvePluginLoaderModuleConfig(params: { modulePath: string; argv1?: string; moduleUrl: string; @@ -757,13 +757,13 @@ export function resolvePluginLoaderJitiConfig(params: { aliasMap: Record; cacheKey: string; } { - const configCacheKey = buildPluginLoaderJitiConfigCacheKey(params); - const cached = pluginLoaderJitiConfigCache.get(configCacheKey); + const configCacheKey = buildPluginLoaderModuleConfigCacheKey(params); + const cached = pluginLoaderModuleConfigCache.get(configCacheKey); if (cached) { return cached; } - const tryNative = resolvePluginLoaderJitiTryNative( + const tryNative = resolvePluginLoaderTryNative( params.modulePath, params.preferBuiltDist ? { preferBuiltDist: true } : {}, ); @@ -776,12 +776,12 @@ export function resolvePluginLoaderJitiConfig(params: { const result = { tryNative, aliasMap, - cacheKey: createPluginLoaderJitiCacheKey({ + cacheKey: createPluginLoaderModuleCacheKey({ tryNative, aliasMap, }), }; - setBoundedCacheValue(pluginLoaderJitiConfigCache, configCacheKey, result); + setBoundedCacheValue(pluginLoaderModuleConfigCache, configCacheKey, result); return result; } diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index 8d44561eb02..009bb3cb419 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -9,9 +9,9 @@ import { resetRegistryJitiMocks, } from "./test-helpers/registry-jiti-mocks.js"; -// jiti-loader-cache prefers native require() for compiled .js before falling +// plugin-module-loader-cache prefers native require() for compiled .js before falling // back to jiti. These tests scripts plugin-loading behaviour through the -// jiti mock — disable the native-require fast path so the mocked jiti loader +// source-transform mock — disable the native-require fast path so the mocked source transformer // stays authoritative for the test fixture files on disk. vi.mock("./native-module-require.js", () => ({ isJavaScriptModulePath: (_modulePath: string) => false, @@ -171,7 +171,7 @@ afterEach(() => { cleanupTrackedTempDirs(tempDirs); }); -describe("setup-registry getJiti", () => { +describe("setup-registry module loader", () => { beforeEach(async () => { resetRegistryJitiMocks(); vi.resetModules(); @@ -185,7 +185,7 @@ describe("setup-registry getJiti", () => { clearPluginSetupRegistryCache(); }); - it("uses the runtime-supported Jiti boundary on Windows for setup-api modules", () => { + it("uses the runtime-supported source-transform boundary on Windows for setup-api modules", () => { const pluginRoot = makeTempDir(); fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); mocks.loadPluginManifestRegistry.mockReturnValue({ diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index a2957f9b903..d1caef98be9 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -5,8 +5,11 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildPluginApi } from "./api-builder.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; -import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; +import { + getCachedPluginModuleLoader, + type PluginModuleLoaderCache, +} from "./plugin-module-loader-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; import type { PluginRuntime } from "./runtime/types.js"; import { listSetupCliBackendIds, listSetupProviderIds } from "./setup-descriptors.js"; @@ -82,15 +85,15 @@ const NOOP_LOGGER: PluginLogger = { error() {}, }; -const jitiLoaders: PluginJitiLoaderCache = new Map(); +const moduleLoaders: PluginModuleLoaderCache = new Map(); export function clearPluginSetupRegistryCache(): void { - jitiLoaders.clear(); + moduleLoaders.clear(); } -function getJiti(modulePath: string) { - return getCachedPluginJitiLoader({ - cache: jitiLoaders, +function getModuleLoader(modulePath: string) { + return getCachedPluginModuleLoader({ + cache: moduleLoaders, modulePath, importerUrl: import.meta.url, }); @@ -224,7 +227,7 @@ function resolveSetupRegistration(record: PluginManifestRecord): { let mod: OpenClawPluginModule; try { - mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule; + mod = getModuleLoader(setupSource)(setupSource) as OpenClawPluginModule; } catch { return null; } diff --git a/src/plugins/source-loader.ts b/src/plugins/source-loader.ts index e375e7ffe85..e0f9863ada1 100644 --- a/src/plugins/source-loader.ts +++ b/src/plugins/source-loader.ts @@ -1,23 +1,23 @@ -import type { PluginJitiLoaderCache } from "./jiti-loader-cache.js"; -import { getCachedPluginJitiLoader } from "./jiti-loader-cache.js"; import { withProfile } from "./plugin-load-profile.js"; +import type { PluginModuleLoaderCache } from "./plugin-module-loader-cache.js"; +import { getCachedPluginSourceModuleLoader } from "./plugin-module-loader-cache.js"; export type PluginSourceLoader = (modulePath: string) => unknown; export function createPluginSourceLoader(): PluginSourceLoader { - const loaders: PluginJitiLoaderCache = new Map(); + const loaders: PluginModuleLoaderCache = new Map(); return (modulePath) => { - const jiti = getCachedPluginJitiLoader({ + const sourceLoader = getCachedPluginSourceModuleLoader({ cache: loaders, modulePath, importerUrl: import.meta.url, - jitiFilename: import.meta.url, + loaderFilename: import.meta.url, }); // Direct source loads are not associated with a specific plugin id — // preserve the existing `plugin=(direct)` field used by tooling that // scrapes [plugin-load-profile] lines. return withProfile({ pluginId: "(direct)", source: modulePath }, "source-loader", () => - jiti(modulePath), + sourceLoader(modulePath), ); }; }