fix runtime deps update from legacy symlinks

This commit is contained in:
Mark Goldenstein
2026-04-30 15:29:24 -07:00
committed by Peter Steinberger
parent d61c919106
commit 1e6bdf3a55
4 changed files with 84 additions and 0 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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: {