diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 70aecfced78..70b9d911eef 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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 + // `/dist/extensions/`, 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( diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 42d301186de..324eb3eae1e 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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 `/` and any descendant such as + // `//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; }