Files
openclaw/src/channels/plugins/module-loader.ts
Peter Steinberger 0ecd449a43 fix(plugins): report missing plugin modules as missing, not boundary escapes (#115053)
* fix(plugins): report missing plugin modules as missing, not boundary escapes

The root-scoped open helper returns a classified failure, but five plugin
loader sites collapsed every failure into "escapes plugin root or fails
alias checks". A plugin artifact that is simply absent — e.g. while
dist/extensions/<id> is being re-emitted by a build — was therefore
logged as a containment violation.

Classify the failure instead: missing (ENOENT/ENOTDIR), unreadable
(coded), or an actual boundary/alias rejection. The containment check is
unchanged; only the reported reason is. Also drops the never-supplied
boundaryLabel/boundaryRootDir parameters on loadChannelPluginModule so
one root carries one label.

* test(infra): rename lint-flagged local helper in boundary failure test
2026-07-28 07:06:24 -04:00

129 lines
4.2 KiB
TypeScript

/**
* Channel plugin module loader.
*
* Loads JavaScript or source plugin modules through native require or cached TS loaders.
*/
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { describeRootFileOpenFailure, openRootFileSync } from "../../infra/boundary-file-read.js";
import { isJavaScriptModulePath } from "../../plugins/native-module-require.js";
import {
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "../../plugins/plugin-module-loader-cache.js";
const nodeRequire = createRequire(import.meta.url);
const SOURCE_MODULE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
const jitiLoaders: PluginModuleLoaderCache = new Map();
function hasNativeSourceRequireHook(modulePath: string): boolean {
const extension = path.extname(modulePath).toLowerCase();
return (
SOURCE_MODULE_EXTENSIONS.has(extension) &&
typeof nodeRequire.extensions?.[extension] === "function"
);
}
function isSourceModulePath(modulePath: string): boolean {
return SOURCE_MODULE_EXTENSIONS.has(path.extname(modulePath).toLowerCase());
}
function loadModuleWithJiti(modulePath: string): unknown {
const loadWithJiti = getCachedPluginModuleLoader({
cache: jitiLoaders,
modulePath,
importerUrl: import.meta.url,
loaderFilename: import.meta.url,
tryNative: false,
cacheScopeKey: "channel-plugin-module-loader",
});
return loadWithJiti(modulePath);
}
function loadModule(modulePath: string): unknown {
if (!isJavaScriptModulePath(modulePath) && !hasNativeSourceRequireHook(modulePath)) {
if (isSourceModulePath(modulePath)) {
// Local source plugins need the TS loader unless the current runtime has
// installed a native source require hook for that extension.
return loadModuleWithJiti(modulePath);
}
throw new Error(`channel plugin module must be built JavaScript: ${modulePath}`);
}
try {
return nodeRequire(modulePath);
} catch (error) {
if (isSourceModulePath(modulePath)) {
// Native source hooks can still fail on ESM/TS edge cases; fall back to
// the cached loader before surfacing the error.
return loadModuleWithJiti(modulePath);
}
throw new Error(`failed to load channel plugin module with native require: ${modulePath}`, {
cause: error,
});
}
}
function resolvePluginModuleCandidates(rootDir: string, specifier: string): string[] {
const normalizedSpecifier = specifier.replace(/\\/g, "/");
const resolvedPath = path.resolve(rootDir, normalizedSpecifier);
const ext = path.extname(resolvedPath);
if (ext) {
return [resolvedPath];
}
return [
resolvedPath,
`${resolvedPath}.ts`,
`${resolvedPath}.mts`,
`${resolvedPath}.js`,
`${resolvedPath}.mjs`,
`${resolvedPath}.cts`,
`${resolvedPath}.cjs`,
];
}
/**
* Resolves a plugin-relative module specifier to an existing candidate path.
*/
export function resolveExistingPluginModulePath(rootDir: string, specifier: string): string {
for (const candidate of resolvePluginModuleCandidates(rootDir, specifier)) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return path.resolve(rootDir, specifier);
}
/**
* Loads a channel plugin module after enforcing plugin-root file boundaries.
*
* `rootDir` is always the plugin's own directory, so the containment failure is
* reported against that one root; no caller boundary override exists.
*/
export function loadChannelPluginModule(params: { modulePath: string; rootDir: string }): unknown {
const boundaryLabel = "plugin root";
const opened = openRootFileSync({
absolutePath: params.modulePath,
rootPath: params.rootDir,
boundaryLabel,
rejectHardlinks: false,
skipLexicalRootCheck: true,
});
if (!opened.ok) {
throw new Error(
describeRootFileOpenFailure({
failure: opened,
subject: "plugin module path",
boundaryLabel,
filePath: params.modulePath,
}),
{ cause: opened.error },
);
}
const safePath = opened.path;
// The boundary check opens the file to verify the path; close before loading
// through require/jiti so module evaluation owns its own descriptor lifecycle.
fs.closeSync(opened.fd);
return loadModule(safePath);
}