From 1e6bdf3a553335d1429a81242dfcb30a43f0968c Mon Sep 17 00:00:00 2001 From: Mark Goldenstein Date: Thu, 30 Apr 2026 15:29:24 -0700 Subject: [PATCH] fix runtime deps update from legacy symlinks --- .../lib/bundled-runtime-deps-materialize.mjs | 3 ++ .../lib/bundled-runtime-deps-stage-state.mjs | 40 +++++++++++++++++++ scripts/stage-bundled-plugin-runtime-deps.mjs | 3 ++ .../stage-bundled-plugin-runtime-deps.test.ts | 38 ++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/scripts/lib/bundled-runtime-deps-materialize.mjs b/scripts/lib/bundled-runtime-deps-materialize.mjs index d3004b6751e..9509c705a89 100644 --- a/scripts/lib/bundled-runtime-deps-materialize.mjs +++ b/scripts/lib/bundled-runtime-deps-materialize.mjs @@ -11,6 +11,7 @@ import { pruneStagedRuntimeDependencyCargo } from "./bundled-runtime-deps-prune. import { assertPathIsNotSymlink, makePluginOwnedTempDir, + removeLegacyBundledRuntimeDepsSymlink, removeOwnedTempPathBestEffort, removePathIfExists, replaceDirAtomically, @@ -155,6 +156,7 @@ export function stageInstalledRootRuntimeDeps(params) { const rootsToCopy = selectRuntimeDependencyRootsToCopy(resolution); const nodeModulesDir = path.join(pluginDir, "node_modules"); if (rootsToCopy.length === 0) { + removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); assertPathIsNotSymlink(nodeModulesDir, "remove runtime deps"); removePathIfExists(nodeModulesDir); writeJsonAtomically(stampPath, { @@ -196,6 +198,7 @@ export function stageInstalledRootRuntimeDeps(params) { } pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig); + removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); replaceDirAtomically(nodeModulesDir, stagedNodeModulesDir); writeJsonAtomically(stampPath, { cheapFingerprint, diff --git a/scripts/lib/bundled-runtime-deps-stage-state.mjs b/scripts/lib/bundled-runtime-deps-stage-state.mjs index 1349b8baaea..fa246ada1fe 100644 --- a/scripts/lib/bundled-runtime-deps-stage-state.mjs +++ b/scripts/lib/bundled-runtime-deps-stage-state.mjs @@ -95,6 +95,46 @@ export function assertPathIsNotSymlink(targetPath, label) { } } +function isPathInside(parentPath, childPath) { + const relativePath = path.relative(parentPath, childPath); + return ( + relativePath.length > 0 && !relativePath.startsWith("..") && !path.isAbsolute(relativePath) + ); +} + +export function removeLegacyBundledRuntimeDepsSymlink(targetPath, repoRoot) { + let stats; + try { + stats = fs.lstatSync(targetPath); + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; + } + if (!stats.isSymbolicLink()) { + return false; + } + + const legacyRuntimeDepsRoot = path.resolve(repoRoot, ".local", "bundled-plugin-runtime-deps"); + let linkedPath; + try { + linkedPath = fs.readlinkSync(targetPath); + } catch { + return false; + } + const resolvedLinkedPath = path.resolve(path.dirname(targetPath), linkedPath); + if ( + path.basename(resolvedLinkedPath) !== "node_modules" || + !isPathInside(legacyRuntimeDepsRoot, resolvedLinkedPath) + ) { + return false; + } + + removePathIfExists(targetPath); + return true; +} + export function replaceDirAtomically(targetPath, sourcePath) { assertPathIsNotSymlink(targetPath, "replace runtime deps"); const targetParentDir = path.dirname(targetPath); diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 87f7756d903..3e611b36bae 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -24,6 +24,7 @@ import { import { assertPathIsNotSymlink, makePluginOwnedTempDir, + removeLegacyBundledRuntimeDepsSymlink, removeOwnedTempPathBestEffort, removePathIfExists, removeStaleRuntimeDepsTempDirs, @@ -323,8 +324,10 @@ function installPluginRuntimeDeps(params) { } if (fs.existsSync(stagedNodeModulesDir)) { pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig); + removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); replaceDirAtomically(nodeModulesDir, stagedNodeModulesDir); } else { + removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); assertPathIsNotSymlink(nodeModulesDir, "remove runtime deps"); removePathIfExists(nodeModulesDir); } diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index c58c4f1acb7..cfdca078aff 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -708,6 +708,44 @@ describe("stageBundledPluginRuntimeDeps", () => { ); }); + it("replaces legacy OpenClaw-owned symlinked plugin node_modules", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { direct: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const directDir = path.join(repoRoot, "node_modules", "direct"); + const legacyNodeModulesDir = path.join( + repoRoot, + ".local", + "bundled-plugin-runtime-deps", + "fixture-plugin-1234567890abcdef", + "node_modules", + ); + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(directDir, { recursive: true }); + fs.mkdirSync(legacyNodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(directDir, "package.json"), + '{ "name": "direct", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); + fs.writeFileSync(path.join(legacyNodeModulesDir, "legacy.js"), "module.exports = 0;\n", "utf8"); + fs.symlinkSync(legacyNodeModulesDir, nodeModulesDir); + + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + + expect(fs.lstatSync(nodeModulesDir).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(path.join(nodeModulesDir, "direct", "index.js"), "utf8")).toBe( + "module.exports = 'direct';\n", + ); + expect(fs.existsSync(path.join(legacyNodeModulesDir, "legacy.js"))).toBe(true); + }); + it("refuses to write a runtime deps stamp through a symlink", () => { const { repoRoot } = createBundledPluginFixture({ packageJson: {