mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 15:51:45 +00:00
* 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
129 lines
4.2 KiB
TypeScript
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);
|
|
}
|