fix(bundled-runtime-deps): collapse nested cache pluginRoot to enclosing key

When a bundled plugin (e.g. plugin-sdk loaded transitively) is resolved via a
pluginRoot already inside the existing plugin-runtime-deps cache, its path
does not match the `dist/extensions/<plugin>` shape, so
resolveBundledPluginPackageRoot() returns null and the caller falls back to
the raw pluginRoot. resolveExistingExternalBundledRuntimeDepsRoots() then
rejected the path because the relative segment crossed a directory separator,
causing the resolver to mint a fresh `openclaw-unknown-<pathhash>` cache
beside the real versioned one. The two caches raced replaceNodeModulesDir()
and triggered ENOTEMPTY crash loops.

Treat any descendant of `<base>/openclaw-*` as belonging to that cache key
so nested resolutions return the existing versioned root instead of creating
a self-referential zombie cache.

Fixes #72956
This commit is contained in:
SymbolStar
2026-04-28 11:16:23 +08:00
committed by Ayaan Zaidi
parent 424560c6c2
commit dfaa06fe15
2 changed files with 48 additions and 10 deletions

View File

@@ -1696,6 +1696,42 @@ describe("ensureBundledPluginRuntimeDeps", () => {
).toEqual({ installedSpecs: [], retainSpecs: [] });
});
it("resolves nested cache pluginRoot to enclosing versioned cache (regression for #72956)", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.25" }),
);
const pluginRoot = path.join(packageRoot, "dist", "extensions", "telegram");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({ dependencies: { grammy: "^1.42.0" } }),
);
const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env });
// Simulate a deeply-nested pluginRoot inside the existing cache directory
// (e.g. plugin-sdk loaded as a transitive dep). The path no longer matches
// `<root>/dist/extensions/<plugin>`, so resolveBundledPluginPackageRoot()
// returns null and the caller previously fell back to the raw pluginRoot,
// generating a self-referential `openclaw-unknown-*` cache directory.
const nestedPluginRoot = path.join(
installRoot,
"dist",
"extensions",
"node_modules",
"openclaw",
"plugin-sdk",
);
fs.mkdirSync(nestedPluginRoot, { recursive: true });
const resolved = resolveBundledRuntimeDependencyInstallRoot(nestedPluginRoot, { env });
expect(resolved).toBe(installRoot);
expect(path.basename(resolved).startsWith("openclaw-unknown-")).toBe(false);
});
it("links source-checkout runtime deps from the cache instead of copying them", () => {
const packageRoot = makeTempDir();
fs.writeFileSync(

View File

@@ -977,18 +977,20 @@ function resolveExistingExternalBundledRuntimeDepsRoots(params: {
const externalBaseDirs = resolveBundledRuntimeDepsExternalBaseDirs(params.env);
for (const externalBaseDir of externalBaseDirs) {
const relative = path.relative(path.resolve(externalBaseDir), packageRoot);
if (
relative === "" ||
relative.startsWith("..") ||
path.isAbsolute(relative) ||
relative.includes(path.sep)
) {
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
continue;
}
const packageKey = path.basename(packageRoot);
return packageKey.startsWith("openclaw-")
? externalBaseDirs.map((baseDir) => path.join(baseDir, packageKey))
: null;
// Accept both `<base>/<key>` and any descendant such as
// `<base>/<key>/dist/extensions/node_modules/openclaw/plugin-sdk`.
// Without this, when a bundled package re-enters resolution via a nested
// `pluginRoot` (e.g. plugin-sdk loaded as a dependency), the caller falls
// back to `params.pluginRoot`, which lacks a `package.json`, producing a
// self-referential `openclaw-unknown-*` cache directory (#72956).
const packageKey = relative.split(path.sep)[0];
if (!packageKey || !packageKey.startsWith("openclaw-")) {
continue;
}
return externalBaseDirs.map((baseDir) => path.join(baseDir, packageKey));
}
return null;
}