fix(plugins): load mirrored runtime deps through ESM-safe aliases

This commit is contained in:
Peter Steinberger
2026-04-25 14:15:34 +01:00
parent d2ab6b4fd5
commit 9e9aa4722a
10 changed files with 191 additions and 42 deletions

View File

@@ -92,7 +92,7 @@ describe("channel plugin module loader helpers", () => {
expect(createJiti).not.toHaveBeenCalled();
});
it("keeps Windows dist loads off Jiti native import", async () => {
it("uses native Jiti import for Windows dist loads", async () => {
const createJiti = vi.fn(() => vi.fn(() => ({ ok: true })));
vi.doMock("jiti", () => ({
createJiti,
@@ -119,7 +119,7 @@ describe("channel plugin module loader helpers", () => {
expect(createJiti).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
} finally {

View File

@@ -108,7 +108,7 @@ describe("plugin-sdk facade loader", () => {
expect(listImportedFacadeRuntimeIds()).toEqual(["demo"]);
});
it("keeps Windows dist facade loads off Jiti native import", () => {
it("uses native Jiti import for Windows dist facade loads", () => {
const dir = createTempDirSync("openclaw-facade-loader-windows-dist-");
const bundledPluginsDir = path.join(dir, "dist");
fs.mkdirSync(path.join(bundledPluginsDir, "demo"), { recursive: true });
@@ -139,7 +139,7 @@ describe("plugin-sdk facade loader", () => {
expect(createJitiCalls[0]?.[0]).toEqual(expect.any(String));
expect(createJitiCalls[0]?.[1]).toEqual(
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
} finally {

View File

@@ -122,6 +122,7 @@ function prepareBundledPluginRuntimeDistMirror(params: {
const mirrorDistRoot = path.join(params.installRoot, "dist");
const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions");
fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 });
ensureBundledRuntimeDistPackageJson(mirrorDistRoot);
for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) {
if (entry.name === "extensions") {
continue;
@@ -145,6 +146,14 @@ function prepareBundledPluginRuntimeDistMirror(params: {
return mirrorExtensionsRoot;
}
function ensureBundledRuntimeDistPackageJson(mirrorDistRoot: string): void {
const packageJsonPath = path.join(mirrorDistRoot, "package.json");
if (fs.existsSync(packageJsonPath)) {
return;
}
writeRuntimeJsonFile(packageJsonPath, { type: "module" });
}
function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void {
fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 });
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {

View File

@@ -34,7 +34,7 @@ describe("doctor-contract-registry getJiti", () => {
clearPluginDoctorContractRegistryCache();
});
it("disables native jiti loading on Windows for contract-api modules", () => {
it("uses native jiti loading on Windows for contract-api modules", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "contract-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
@@ -56,7 +56,7 @@ describe("doctor-contract-registry getJiti", () => {
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "contract-api.js"));
expect(mocks.createJiti.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
});

View File

@@ -1582,7 +1582,11 @@ module.exports = {
name: "root-support",
version: "1.0.0",
type: "module",
exports: "./index.js",
exports: {
".": {
import: "./index.js",
},
},
}),
"utf-8",
);
@@ -1602,10 +1606,11 @@ module.exports = {
path.join(pluginRoot, "index.js"),
[
`import { marker } from "../../manifest-support.js";`,
`import externalRuntime from "external-runtime";`,
`export default {`,
` id: "alpha",`,
` register(api) {`,
` api.registerCommand({ name: "root-support", handler: () => marker });`,
` api.registerCommand({ name: "root-support", handler: () => [marker, externalRuntime.marker].join(":") });`,
` },`,
`};`,
"",
@@ -1619,6 +1624,9 @@ module.exports = {
name: "@openclaw/alpha",
version: "1.0.0",
type: "module",
dependencies: {
"external-runtime": "1.0.0",
},
openclaw: { extensions: ["./index.js"] },
},
null,
@@ -1650,6 +1658,29 @@ module.exports = {
registry = loadOpenClawPlugins({
cache: false,
config: { plugins: { enabled: true } },
bundledRuntimeDepsInstaller: ({ installRoot }) => {
const depRoot = path.join(installRoot, "node_modules", "external-runtime");
fs.mkdirSync(depRoot, { recursive: true });
fs.writeFileSync(
path.join(depRoot, "package.json"),
JSON.stringify({
name: "external-runtime",
version: "1.0.0",
type: "module",
exports: {
".": {
import: "./index.js",
},
},
}),
"utf-8",
);
fs.writeFileSync(
path.join(depRoot, "index.js"),
"export default { marker: 'external-ok' };\n",
"utf-8",
);
},
});
} finally {
symlinkSync.mockRestore();

View File

@@ -120,6 +120,7 @@ import {
resolvePluginSdkAliasFile,
resolvePluginRuntimeModulePath,
resolvePluginSdkScopedAliasMap,
normalizeJitiAliasTargetPath,
shouldPreferNativeJiti,
} from "./sdk-alias.js";
import { hasKind, kindsEqual } from "./slots.js";
@@ -257,7 +258,7 @@ export function clearPluginLoaderCache(): void {
inFlightPluginRegistryLoads.clear();
openAllowlistWarningCache.clear();
clearBundledRuntimeDependencyNodePaths();
registeredBundledRuntimeDepMirrorRoots.clear();
bundledRuntimeDependencyJitiAliases.clear();
clearAgentHarnesses();
clearPluginCommands();
clearCompactionProviders();
@@ -457,16 +458,133 @@ function toSafeImportPath(specifier: string): string {
return specifier;
}
type RuntimeDependencyPackageJson = {
dependencies?: Record<string, unknown>;
optionalDependencies?: Record<string, unknown>;
peerDependencies?: Record<string, unknown>;
exports?: unknown;
module?: string;
main?: string;
};
const bundledRuntimeDependencyJitiAliases = new Map<string, string>();
function readRuntimeDependencyPackageJson(
packageJsonPath: string,
): RuntimeDependencyPackageJson | null {
try {
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as RuntimeDependencyPackageJson;
} catch {
return null;
}
}
function collectRuntimeDependencyNames(pkg: RuntimeDependencyPackageJson): string[] {
return [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.optionalDependencies ?? {}),
...Object.keys(pkg.peerDependencies ?? {}),
].toSorted((left, right) => left.localeCompare(right));
}
function resolveRuntimePackageImportTarget(exportsField: unknown): string | null {
if (typeof exportsField === "string") {
return exportsField;
}
if (Array.isArray(exportsField)) {
for (const entry of exportsField) {
const resolved = resolveRuntimePackageImportTarget(entry);
if (resolved) {
return resolved;
}
}
return null;
}
if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) {
return null;
}
const record = exportsField as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(record, ".")) {
return resolveRuntimePackageImportTarget(record["."]);
}
for (const condition of ["import", "node", "default"] as const) {
const resolved = resolveRuntimePackageImportTarget(record[condition]);
if (resolved) {
return resolved;
}
}
return null;
}
function registerBundledRuntimeDependencyJitiAliases(rootDir: string): void {
const rootPackageJson = readRuntimeDependencyPackageJson(path.join(rootDir, "package.json"));
if (!rootPackageJson) {
return;
}
for (const dependencyName of collectRuntimeDependencyNames(rootPackageJson)) {
const dependencyPackageJsonPath = path.join(
rootDir,
"node_modules",
...dependencyName.split("/"),
"package.json",
);
const dependencyPackageJson = readRuntimeDependencyPackageJson(dependencyPackageJsonPath);
if (!dependencyPackageJson) {
continue;
}
const entry =
resolveRuntimePackageImportTarget(dependencyPackageJson.exports) ??
dependencyPackageJson.module ??
dependencyPackageJson.main;
if (!entry || entry.startsWith("#")) {
continue;
}
const dependencyRoot = path.dirname(dependencyPackageJsonPath);
const targetPath = path.resolve(dependencyRoot, entry);
if (!isPathInside(dependencyRoot, targetPath) || !fs.existsSync(targetPath)) {
continue;
}
bundledRuntimeDependencyJitiAliases.set(
dependencyName,
normalizeJitiAliasTargetPath(targetPath),
);
}
}
function resolveBundledRuntimeDependencyJitiAliasMap(): Record<string, string> | undefined {
if (bundledRuntimeDependencyJitiAliases.size === 0) {
return undefined;
}
return Object.fromEntries(
[...bundledRuntimeDependencyJitiAliases.entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
),
);
}
function createPluginJitiLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
const jitiLoaders: PluginJitiLoaderCache = new Map();
return (modulePath: string) => {
const tryNative =
shouldPreferNativeJiti(modulePath) && !isBundledRuntimeDependencyMirrorPath(modulePath);
const tryNative = shouldPreferNativeJiti(modulePath);
const runtimeAliasMap = resolveBundledRuntimeDependencyJitiAliasMap();
return getCachedPluginJitiLoader({
cache: jitiLoaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: modulePath,
...(runtimeAliasMap
? {
aliasMap: {
...buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
options.pluginSdkResolution,
),
...runtimeAliasMap,
},
}
: {}),
pluginSdkResolution: options.pluginSdkResolution,
// Source .ts runtime shims import sibling ".js" specifiers that only exist
// after build. Disable native loading for source entries so Jiti rewrites
@@ -487,25 +605,6 @@ function resolveCanonicalDistRuntimeSource(source: string): string {
return fs.existsSync(candidate) ? candidate : source;
}
const registeredBundledRuntimeDepMirrorRoots = new Set<string>();
function isBundledRuntimeDependencyMirrorPath(modulePath: string): boolean {
const resolvedModulePath = path.resolve(modulePath);
for (const installRoot of registeredBundledRuntimeDepMirrorRoots) {
if (
resolvedModulePath === installRoot ||
resolvedModulePath.startsWith(`${installRoot}${path.sep}`)
) {
return true;
}
}
return false;
}
function registerBundledRuntimeDependencyMirrorRoot(installRoot: string): void {
registeredBundledRuntimeDepMirrorRoots.add(path.resolve(installRoot));
}
function mirrorBundledPluginRuntimeRoot(params: {
pluginId: string;
pluginRoot: string;
@@ -551,6 +650,7 @@ function prepareBundledPluginRuntimeDistMirror(params: {
const mirrorDistRoot = path.join(params.installRoot, sourceDistRootName);
const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions");
fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 });
ensureBundledRuntimeDistPackageJson(mirrorDistRoot);
for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) {
if (entry.name === "extensions") {
continue;
@@ -592,6 +692,14 @@ function prepareBundledPluginRuntimeDistMirror(params: {
return mirrorExtensionsRoot;
}
function ensureBundledRuntimeDistPackageJson(mirrorDistRoot: string): void {
const packageJsonPath = path.join(mirrorDistRoot, "package.json");
if (fs.existsSync(packageJsonPath)) {
return;
}
writeRuntimeJsonFile(packageJsonPath, { type: "module" });
}
function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void {
fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 });
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
@@ -2358,9 +2466,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const packageRoot = resolveBundledRuntimeDependencyPackageRoot(pluginRoot);
if (packageRoot) {
registerBundledRuntimeDependencyNodePath(packageRoot);
registerBundledRuntimeDependencyJitiAliases(packageRoot);
}
registerBundledRuntimeDependencyNodePath(installRoot);
registerBundledRuntimeDependencyMirrorRoot(installRoot);
registerBundledRuntimeDependencyJitiAliases(installRoot);
runtimePluginRoot = mirrorBundledPluginRuntimeRoot({
pluginId: record.id,
pluginRoot,

View File

@@ -28,7 +28,7 @@ afterEach(() => {
});
describe("bundled plugin public surface loader", () => {
it("keeps Windows dist public artifact loads off Jiti native import", async () => {
it("uses native Jiti import for Windows dist public artifact loads", async () => {
const createJiti = vi.fn(() => vi.fn(() => ({ marker: "windows-dist-ok" })));
vi.doMock("jiti", () => ({
createJiti,
@@ -56,7 +56,7 @@ describe("bundled plugin public surface loader", () => {
expect(createJiti).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
} finally {

View File

@@ -870,7 +870,7 @@ describe("plugin sdk alias helpers", () => {
}
});
it("disables native Jiti loads on Windows even for built JavaScript entries", () => {
it("prefers native Jiti loads on Windows for built JavaScript entries", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
@@ -878,9 +878,9 @@ describe("plugin sdk alias helpers", () => {
});
try {
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(false);
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(shouldPreferNativeJiti(`/repo/${bundledDistPluginFile("browser", "index.js")}`)).toBe(
false,
true,
);
} finally {
Object.defineProperty(process, "platform", {
@@ -890,7 +890,7 @@ describe("plugin sdk alias helpers", () => {
}
});
it("keeps plugin loader dist shortcuts off on Windows", () => {
it("keeps plugin loader dist shortcuts native on Windows", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
@@ -902,7 +902,7 @@ describe("plugin sdk alias helpers", () => {
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {
preferBuiltDist: true,
}),
).toBe(false);
).toBe(true);
expect(
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, {
preferBuiltDist: true,
@@ -918,7 +918,7 @@ describe("plugin sdk alias helpers", () => {
it("prefers native jiti 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 Linux/macOS for
// to shouldPreferNativeJiti() — which returns true on Node for
// compiled artifacts, avoiding the slow jiti transform path.
expect(
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {

View File

@@ -695,7 +695,7 @@ export function buildPluginLoaderJitiOptions(aliasMap: Record<string, string>) {
function supportsNativeJitiRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun !== "string" && process.platform !== "win32";
return typeof versions.bun !== "string";
}
function isBundledPluginDistModulePath(modulePath: string): boolean {

View File

@@ -158,7 +158,7 @@ describe("setup-registry getJiti", () => {
clearPluginSetupRegistryCache();
});
it("disables native jiti loading on Windows for setup-api modules", () => {
it("uses native jiti loading 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({
@@ -180,7 +180,7 @@ describe("setup-registry getJiti", () => {
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "setup-api.js"));
expect(mocks.createJiti.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
});