fix: harden openclaw peer dependency installs (#70462)

This commit is contained in:
Peter Steinberger
2026-04-23 20:15:28 +01:00
parent 44820f859e
commit e93b3f60fa
3 changed files with 12 additions and 27 deletions

View File

@@ -3,13 +3,13 @@ import path from "node:path";
import * as tar from "tar";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { safePathSegmentHashed } from "../infra/install-safe-path.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { expectSingleNpmInstallIgnoreScriptsCall } from "../test-utils/exec-assertions.js";
import { expectInstallUsesIgnoreScripts } from "../test-utils/npm-spec-install-test-helpers.js";
import { initializeGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js";
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
import * as installSecurityScan from "./install-security-scan.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import {
installPluginFromArchive,
installPluginFromDir,
@@ -2380,6 +2380,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
it("creates a node_modules/openclaw symlink when peerDependencies declares openclaw", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const fakeHostRoot = suiteTempRootTracker.makeTempDir();
const run = vi.mocked(runCommandWithTimeout);
resolveRootMock.mockReturnValue(fakeHostRoot);
writePluginWithPeerDeps(pluginDir, { openclaw: "*" });
@@ -2395,6 +2396,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
const stat = fs.lstatSync(symlinkPath);
expect(stat.isSymbolicLink()).toBe(true);
expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot));
expect(run).not.toHaveBeenCalled();
});
it("does not create a symlink when peerDependencies is empty", async () => {
@@ -2415,7 +2417,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
expect(fs.existsSync(symlinkPath)).toBe(false);
});
it("is idempotent re-installing replaces an existing symlink without error", async () => {
it("is idempotent - re-installing replaces an existing symlink without error", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const fakeHostRoot = suiteTempRootTracker.makeTempDir();
resolveRootMock.mockReturnValue(fakeHostRoot);
@@ -2426,7 +2428,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
const { result: first } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(first.ok).toBe(true);
// Second install (update mode) should replace symlink, not throw
// Second install (update mode) should replace symlink, not throw.
const { result: second, warnings } = await installFromDirWithWarnings({
pluginDir,
extensionsDir,

View File

@@ -611,10 +611,8 @@ async function detectNativePackageInstallSource(packageDir: string): Promise<boo
}
/**
* After npm install completes, symlink any peerDependencies that name the host
* openclaw package into the plugin's node_modules/ directory. npm never
* materialises peerDependencies automatically, so plugins that moved openclaw
* from dependencies → peerDependencies would fail at runtime without this.
* After the staged plugin tree has been scanned, symlink the host openclaw
* package for plugins that declare it as a peer dependency.
*/
async function linkOpenClawPeerDependencies(params: {
installedDir: string;
@@ -643,31 +641,15 @@ async function linkOpenClawPeerDependencies(params: {
for (const peerName of peers) {
const linkPath = path.join(nodeModulesDir, peerName);
// Resolve the actual source for scoped packages (e.g. @scope/name).
const linkTarget = path.join(hostRoot, "node_modules", peerName);
// Check whether the package exists at the expected location inside the
// host root's node_modules. If it does not (e.g. openclaw IS the root
// package), fall back to the host root itself.
let resolvedTarget: string;
try {
await fs.access(path.join(linkTarget, "package.json"));
resolvedTarget = linkTarget;
} catch {
resolvedTarget = hostRoot;
}
try {
// Remove any existing entry (broken link or stale directory) before
// creating the new symlink so re-installs are idempotent.
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(resolvedTarget, linkPath, "junction");
params.logger.info?.(
`Linked peerDependency "${peerName}" → ${resolvedTarget}`,
);
await fs.symlink(hostRoot, linkPath, "junction");
params.logger.info?.(`Linked peerDependency "${peerName}" -> ${hostRoot}`);
} catch (err) {
params.logger.warn?.(
`Failed to symlink peerDependency "${peerName}": ${String(err)}`,
);
params.logger.warn?.(`Failed to symlink peerDependency "${peerName}": ${String(err)}`);
}
}
}
@@ -820,7 +802,7 @@ async function installPluginFromPackageDir(
mode: targetResult.target.effectiveMode,
dryRun,
copyErrorPrefix: "failed to copy plugin",
hasDeps: Object.keys(deps).length > 0 || Object.keys(peerDeps).length > 0,
hasDeps: Object.keys(deps).length > 0,
depsLogMessage: "Installing plugin dependencies…",
nameEncoder: encodePluginInstallDirName,
afterCopy: async (installedDir) => {