fix: plan managed npm peer pins with npm

Plan managed npm peer dependency pins from npm's lockfile planner instead of recursively scanning nested node_modules packages, preserving host peer ranges when npm cannot produce a usable root pin.

Also preserves active root-managed OpenClaw host runtimes during npm plugin installs, folding the active-host guard/test from #81632.

Verification:
- codex-review --full-access
- pnpm check:test-types
- pnpm exec oxfmt --check --threads=1 src/infra/npm-managed-root.ts src/infra/npm-managed-root.test.ts src/plugins/install.npm-spec.test.ts CHANGELOG.md test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts && git diff --check
- OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test src/infra/npm-managed-root.test.ts src/plugins/install.npm-spec.test.ts -- --reporter=verbose
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs src/plugins/install.npm-spec.e2e.test.ts -- --reporter=verbose
- node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts test/scripts/mantis-build-telegram-desktop-proof-evidence.test.ts --reporter=verbose
- GitHub current-head checks: 55 completed, 0 failures; remaining Blacksmith-backed jobs capacity-queued at merge decision time.

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-05-14 21:25:22 +01:00
committed by GitHub
parent 6971036043
commit d9ff8cfb01
6 changed files with 1153 additions and 213 deletions
+1
View File
@@ -169,6 +169,7 @@ Docs: https://docs.openclaw.ai
- Plugins/install: preserve third-party peer dependencies in the managed npm root when later plugin installs or updates recalculate the shared dependency tree. Thanks @shakkernerd.
- Plugins/memory: prefer the npm-installed memory-lancedb plugin over the bundled fallback during duplicate resolution, keeping Active Memory's `memory_recall` tool visible after managed installs. Fixes #81193. Thanks @julio-arcila.
- Plugins/uninstall: prune managed third-party peer dependencies after their owning npm plugin is removed, without blocking plugin cleanup on peer-prune failures.
- Plugins/install: derive managed peer dependency pins from npm's lockfile planner instead of recursively scanning `node_modules`, while keeping OpenClaw host peers out of managed root ownership and preserving active root-managed runtimes. Thanks @fuller-stack-dev.
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
- Channels/WeCom: refresh the official onboarding install to `@wecom/wecom-openclaw-plugin@2026.5.7` and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.
- Control UI/WebChat: keep short assistant replies clear of in-bubble copy/open action buttons by applying the existing reserved action spacing in the grouped chat renderer. Fixes #79509. (#81244) Thanks @JARVIS-Glasses.
+413 -2
View File
@@ -3,12 +3,14 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CommandOptions } from "../process/exec.js";
import {
repairManagedNpmRootOpenClawPeer,
removeManagedNpmRootDependency,
readManagedNpmRootInstalledDependency,
readOpenClawManagedNpmRootOverrides,
resolveManagedNpmRootDependencySpec,
syncManagedNpmRootPeerDependencies,
upsertManagedNpmRootDependency,
} from "./npm-managed-root.js";
@@ -53,7 +55,10 @@ async function expectPathMissing(targetPath: string): Promise<void> {
throw new Error(`Expected path to be missing: ${targetPath}`);
}
function requireFirstMockCall<T>(mock: { mock: { calls: T[][] } }, label: string): T[] {
function requireFirstMockCall<T extends unknown[]>(
mock: { mock: { calls: T[] } },
label: string,
): T {
const call = mock.mock.calls[0];
if (!call) {
throw new Error(`expected ${label} call`);
@@ -61,6 +66,16 @@ function requireFirstMockCall<T>(mock: { mock: { calls: T[][] } }, label: string
return call;
}
function requireCommandOptions(
options: number | CommandOptions | undefined,
label: string,
): CommandOptions {
if (!options || typeof options === "number") {
throw new Error(`expected ${label} command options`);
}
return options;
}
describe("managed npm root", () => {
it("keeps existing plugin dependencies when adding another managed plugin", async () => {
const npmRoot = await makeTempRoot();
@@ -358,6 +373,334 @@ describe("managed npm root", () => {
});
});
it("syncs managed peer dependencies from npm's resolved lockfile plan", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
"existing-root": "1.0.0",
"old-peer": "1.0.0",
plugin: "1.0.0",
},
devDependencies: {
"dev-plugin": "1.0.0",
},
openclaw: {
managedPeerDependencies: ["old-peer"],
},
},
null,
2,
)}\n`,
);
const runCommand = vi.fn(async (_args: string[], optionsOrTimeout: number | CommandOptions) => {
const options = requireCommandOptions(optionsOrTimeout, "npm peer plan");
if (!options.cwd) {
throw new Error("expected npm peer plan cwd");
}
const tempManifest = JSON.parse(
await fs.readFile(path.join(options.cwd, "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
};
expect(tempManifest.dependencies).toEqual({
"existing-root": "1.0.0",
plugin: "1.0.0",
});
await fs.writeFile(
path.join(options.cwd, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: tempManifest.dependencies,
},
"node_modules/existing-root": {
version: "1.0.0",
},
"node_modules/dev-peer": {
dev: true,
version: "3.0.0",
},
"node_modules/dev-plugin": {
dev: true,
peerDependencies: {
"dev-peer": "^3.0.0",
},
version: "1.0.0",
},
"node_modules/new-peer": {
peer: true,
version: "2.1.0",
},
"node_modules/openclaw": {
peer: true,
version: "2026.5.12",
},
"node_modules/plugin": {
peerDependencies: {
"existing-root": "^1.0.0",
"new-peer": "^2.0.0",
openclaw: ">=2026.5.0",
},
version: "1.0.0",
},
"node_modules/unsupported-optional": {
optional: true,
os: [process.platform === "win32" ? "darwin" : "win32"],
peerDependencies: {
"unsupported-peer": "^9.0.0",
},
version: "1.0.0",
},
},
},
null,
2,
)}\n`,
);
return successfulSpawn;
});
await expect(syncManagedNpmRootPeerDependencies({ npmRoot, runCommand })).resolves.toBe(true);
const [args, rawOptions] = requireFirstMockCall(runCommand, "npm peer plan command");
const options = requireCommandOptions(rawOptions, "npm peer plan");
expect(args).toEqual([
"npm",
"install",
"--package-lock-only",
"--force",
"--omit=dev",
"--omit=peer",
"--loglevel=error",
"--ignore-scripts",
"--workspaces=false",
"--no-audit",
"--no-fund",
]);
expect(options?.cwd).not.toBe(npmRoot);
expect(options?.env?.npm_config_legacy_peer_deps).toBe("false");
await expect(
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
).resolves.toEqual({
private: true,
dependencies: {
"existing-root": "1.0.0",
"new-peer": "2.1.0",
plugin: "1.0.0",
},
devDependencies: {
"dev-plugin": "1.0.0",
},
openclaw: {
managedPeerDependencies: ["new-peer"],
},
});
});
it("preserves existing managed peer dependencies when npm cannot plan third-party peers", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
plugin: "1.0.0",
"runtime-peer": "2.0.0",
},
openclaw: {
managedPeerDependencies: ["runtime-peer"],
},
},
null,
2,
)}\n`,
);
const runCommand = vi.fn(async () => ({
code: 1,
stdout: "",
stderr: "npm ERR! ERESOLVE could not resolve third-party peer dependency",
signal: null,
killed: false,
termination: "exit" as const,
}));
await expect(syncManagedNpmRootPeerDependencies({ npmRoot, runCommand })).resolves.toBe(false);
expect(runCommand).toHaveBeenCalledTimes(1);
await expect(
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
).resolves.toEqual({
private: true,
dependencies: {
plugin: "1.0.0",
"runtime-peer": "2.0.0",
},
openclaw: {
managedPeerDependencies: ["runtime-peer"],
},
});
});
it("uses lockfile metadata to preserve non-host peers when host peer planning fails", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
plugin: "1.0.0",
},
},
null,
2,
)}\n`,
);
const runCommand = vi.fn(async (_args: string[], optionsOrTimeout: number | CommandOptions) => {
const options = requireCommandOptions(optionsOrTimeout, "npm peer plan");
if (!options.cwd) {
throw new Error("expected npm peer plan cwd");
}
if (runCommand.mock.calls.length === 1) {
return {
code: 1,
stdout: "",
stderr: "npm ERR! notarget No matching version found for openclaw@2026.5.99-beta.1",
signal: null,
killed: false,
termination: "exit" as const,
};
}
await fs.writeFile(
path.join(options.cwd, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: {
plugin: "1.0.0",
},
},
"node_modules/plugin": {
peerDependencies: {
openclaw: "2026.5.99-beta.1",
"runtime-peer": "^2.0.0",
},
version: "1.0.0",
},
},
},
null,
2,
)}\n`,
);
return successfulSpawn;
});
await expect(syncManagedNpmRootPeerDependencies({ npmRoot, runCommand })).resolves.toBe(true);
expect(runCommand).toHaveBeenCalledTimes(2);
const [strictArgs, rawStrictOptions] = runCommand.mock.calls[0] ?? [];
const [fallbackArgs, rawFallbackOptions] = runCommand.mock.calls[1] ?? [];
const strictOptions = requireCommandOptions(rawStrictOptions, "strict npm peer plan");
const fallbackOptions = requireCommandOptions(rawFallbackOptions, "fallback npm peer plan");
expect(strictArgs).not.toContain("--legacy-peer-deps");
expect(strictOptions.env?.npm_config_legacy_peer_deps).toBe("false");
expect(fallbackArgs).toContain("--legacy-peer-deps");
expect(fallbackOptions.env?.npm_config_legacy_peer_deps).toBe("true");
await expect(
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
).resolves.toEqual({
private: true,
dependencies: {
plugin: "1.0.0",
"runtime-peer": "^2.0.0",
},
openclaw: {
managedPeerDependencies: ["runtime-peer"],
},
});
});
it("does not promote nested transitive lockfile versions into managed root peers", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
plugin: "1.0.0",
},
},
null,
2,
)}\n`,
);
const runCommand = vi.fn(async (_args: string[], optionsOrTimeout: number | CommandOptions) => {
const options = requireCommandOptions(optionsOrTimeout, "npm peer plan");
if (!options.cwd) {
throw new Error("expected npm peer plan cwd");
}
await fs.writeFile(
path.join(options.cwd, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: {
plugin: "1.0.0",
},
},
"node_modules/plugin": {
peerDependencies: {
"runtime-peer": "^2.0.0",
},
version: "1.0.0",
},
"node_modules/transitive": {
version: "1.0.0",
},
"node_modules/transitive/node_modules/runtime-peer": {
version: "1.0.0",
},
},
},
null,
2,
)}\n`,
);
return successfulSpawn;
});
await expect(syncManagedNpmRootPeerDependencies({ npmRoot, runCommand })).resolves.toBe(true);
await expect(
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
).resolves.toEqual({
private: true,
dependencies: {
plugin: "1.0.0",
"runtime-peer": "^2.0.0",
},
openclaw: {
managedPeerDependencies: ["runtime-peer"],
},
});
});
it("removes one managed dependency without dropping unrelated metadata", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
@@ -469,7 +812,8 @@ describe("managed npm root", () => {
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot, runCommand })).resolves.toBe(true);
expect(runCommand).toHaveBeenCalledTimes(1);
const [repairArgs, repairOptions] = requireFirstMockCall(runCommand, "repair command");
const [repairArgs, rawRepairOptions] = requireFirstMockCall(runCommand, "repair command");
const repairOptions = requireCommandOptions(rawRepairOptions, "repair");
expect(repairArgs).toEqual([
"npm",
"uninstall",
@@ -508,4 +852,71 @@ describe("managed npm root", () => {
}
await expectPathMissing(path.join(npmRoot, "node_modules", ".package-lock.json"));
});
it("does not repair the active OpenClaw host package in a root-managed install", async () => {
const npmRoot = await makeTempRoot();
const hostPackageRoot = path.join(npmRoot, "node_modules", "openclaw");
await fs.mkdir(path.join(hostPackageRoot, "dist"), { recursive: true });
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
openclaw: "2026.5.12-beta.6",
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(npmRoot, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: {
openclaw: "2026.5.12-beta.6",
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
},
},
"node_modules/openclaw": {
version: "2026.5.12-beta.6",
},
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(hostPackageRoot, "package.json"),
`${JSON.stringify({ name: "openclaw", version: "2026.5.12-beta.6" })}\n`,
);
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
await expect(
repairManagedNpmRootOpenClawPeer({
npmRoot,
packageRoot: hostPackageRoot,
runCommand,
}),
).resolves.toBe(false);
expect(runCommand).not.toHaveBeenCalled();
await expect(
fs.readFile(path.join(npmRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)),
).resolves.toMatchObject({
dependencies: {
openclaw: "2026.5.12-beta.6",
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
},
});
await expect(
fs.readFile(path.join(hostPackageRoot, "package.json"), "utf8"),
).resolves.toContain("2026.5.12-beta.6");
});
});
+380 -162
View File
@@ -1,12 +1,12 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runCommandWithTimeout } from "../process/exec.js";
import type { NpmSpecResolution } from "./install-source-utils.js";
import { readJson, readJsonIfExists, writeJson } from "./json-files.js";
import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js";
import { resolveOpenClawPackageRootSync } from "./openclaw-root.js";
import { createSafeNpmInstallEnv } from "./safe-package-install.js";
import { createSafeNpmInstallArgs, createSafeNpmInstallEnv } from "./safe-package-install.js";
type ManagedNpmRootManifest = {
private?: boolean;
@@ -52,16 +52,6 @@ type ManagedNpmRootLogger = {
type ManagedNpmRootRunCommand = typeof runCommandWithTimeout;
type ManagedNpmPeerTraversalLimits = {
maxDepth: number;
maxDirectories: number;
};
const DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS: ManagedNpmPeerTraversalLimits = {
maxDepth: 64,
maxDirectories: 10_000,
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -83,35 +73,6 @@ function readDependencyRecord(value: unknown): Record<string, string> {
return dependencies;
}
function readPositiveIntegerEnv(name: string, fallback: number): number {
const rawValue = process.env[name];
if (!rawValue) {
return fallback;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isFinite(parsedValue) && parsedValue >= 1 ? parsedValue : fallback;
}
function resolveManagedNpmPeerTraversalLimits(): ManagedNpmPeerTraversalLimits {
return {
maxDepth: readPositiveIntegerEnv(
"OPENCLAW_INSTALL_SCAN_MAX_DEPTH",
DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS.maxDepth,
),
maxDirectories: readPositiveIntegerEnv(
"OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES",
DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS.maxDirectories,
),
};
}
function isSamePathOrInside(parentPath: string, candidatePath: string): boolean {
const relative = path.relative(parentPath, candidatePath);
return (
relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function isSafePackageName(name: string): boolean {
if (name.startsWith("@")) {
const parts = name.split("/");
@@ -124,6 +85,10 @@ function isSafePackageName(name: string): boolean {
);
}
function isManagedNpmRootHostPeerPackageName(name: string): boolean {
return name === "openclaw";
}
function readOverrideRecord(value: unknown): Record<string, unknown> {
if (!isRecord(value)) {
return {};
@@ -315,25 +280,6 @@ export async function upsertManagedNpmRootDependency(params: {
await writeJson(manifestPath, next, { trailingNewline: true });
}
async function readPackageJsonIfExists(
packageDir: string,
): Promise<Record<string, unknown> | null> {
try {
const parsed = JSON.parse(await fs.readFile(path.join(packageDir, "package.json"), "utf8"));
return isRecord(parsed) ? parsed : null;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
}
}
async function readPackageVersion(packageDir: string): Promise<string | undefined> {
const parsed = await readPackageJsonIfExists(packageDir);
return parsed ? readOptionalString(parsed.version) : undefined;
}
function isOptionalPeerDependency(manifest: Record<string, unknown>, peerName: string): boolean {
if (!isRecord(manifest.peerDependenciesMeta)) {
return false;
@@ -342,127 +288,348 @@ function isOptionalPeerDependency(manifest: Record<string, unknown>, peerName: s
return isRecord(peerMetadata) && peerMetadata.optional === true;
}
async function listNodeModulesPackageDirs(nodeModulesDir: string): Promise<string[]> {
let entries: Dirent[];
try {
entries = await fs.readdir(nodeModulesDir, { withFileTypes: true });
} catch (err) {
if (
(err as NodeJS.ErrnoException).code === "ENOENT" ||
(err as NodeJS.ErrnoException).code === "ENOTDIR"
) {
return [];
}
throw err;
}
const packageDirs: string[] = [];
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
if (entry.name === ".bin" || entry.name === "openclaw" || entry.name.startsWith(".")) {
continue;
}
const entryPath = path.join(nodeModulesDir, entry.name);
if (entry.name.startsWith("@") && entry.isDirectory()) {
let scopedEntries: Dirent[];
try {
scopedEntries = await fs.readdir(entryPath, { withFileTypes: true });
} catch (err) {
if (
(err as NodeJS.ErrnoException).code === "ENOENT" ||
(err as NodeJS.ErrnoException).code === "ENOTDIR"
) {
continue;
}
throw err;
}
for (const scopedEntry of scopedEntries.toSorted((left, right) =>
left.name.localeCompare(right.name),
)) {
if (scopedEntry.isDirectory() || scopedEntry.isSymbolicLink()) {
packageDirs.push(path.join(entryPath, scopedEntry.name));
}
}
continue;
}
if (entry.isDirectory() || entry.isSymbolicLink()) {
packageDirs.push(entryPath);
}
}
return packageDirs;
function isDevOnlyLockPackage(value: unknown): boolean {
return isRecord(value) && value.dev === true;
}
async function collectManagedNpmRootPeerDependencyPins(params: {
npmRoot: string;
}): Promise<Record<string, string>> {
const pins = new Map<string, string>();
const limits = resolveManagedNpmPeerTraversalLimits();
const boundaryRealPath = await fs
.realpath(params.npmRoot)
.catch(() => path.resolve(params.npmRoot));
const queue = (await listNodeModulesPackageDirs(path.join(params.npmRoot, "node_modules"))).map(
(packageDir) => ({ depth: 0, packageDir }),
function readStringList(value: unknown): string[] | undefined {
if (typeof value === "string") {
return [value];
}
if (!Array.isArray(value)) {
return undefined;
}
const values = value.filter((entry): entry is string => typeof entry === "string");
return values.length > 0 ? values : undefined;
}
function matchesNpmPlatformList(value: string | undefined, list: string[] | undefined): boolean {
if (!list) {
return true;
}
if (list.length === 1 && list[0] === "any") {
return true;
}
if (!value) {
return false;
}
let negated = 0;
let matched = false;
for (const entry of list) {
const negate = entry.startsWith("!");
const test = negate ? entry.slice(1) : entry;
if (negate) {
negated += 1;
if (value === test) {
return false;
}
} else {
matched = matched || value === test;
}
}
return matched || negated === list.length;
}
function resolveCurrentLibc(): string | undefined {
if (process.platform !== "linux") {
return undefined;
}
const report: unknown = process.report?.getReport();
const header = isRecord(report) ? report.header : undefined;
if (isRecord(header) && header.glibcVersionRuntime) {
return "glibc";
}
const sharedObjects = isRecord(report) ? report.sharedObjects : undefined;
if (
Array.isArray(sharedObjects) &&
sharedObjects.some((file) => typeof file === "string" && file.includes("musl"))
) {
return "musl";
}
return undefined;
}
function isUnsupportedOptionalLockPackage(value: unknown): boolean {
if (!isRecord(value) || value.optional !== true) {
return false;
}
return (
!matchesNpmPlatformList(process.platform, readStringList(value.os)) ||
!matchesNpmPlatformList(process.arch, readStringList(value.cpu)) ||
!matchesNpmPlatformList(resolveCurrentLibc(), readStringList(value.libc))
);
const visitedRealPaths = new Set<string>();
for (let index = 0; index < queue.length; index += 1) {
const current = queue[index];
if (!current) {
}
function readLockPackageName(location: string, value: unknown): string | undefined {
if (isRecord(value)) {
const packageName = readOptionalString(value.name);
if (packageName) {
return packageName;
}
}
const parts = location.split("/");
for (let index = parts.length - 1; index >= 0; index -= 1) {
if (parts[index] !== "node_modules") {
continue;
}
if (current.depth > limits.maxDepth) {
throw new Error(
`managed npm peer dependency scan exceeded max depth (${limits.maxDepth}) at ${current.packageDir}`,
);
const first = parts[index + 1];
if (!first) {
return undefined;
}
const packageDirRealPath = await fs
.realpath(current.packageDir)
.catch(() => path.resolve(current.packageDir));
if (!isSamePathOrInside(boundaryRealPath, packageDirRealPath)) {
throw new Error(
`managed npm peer dependency scan found package outside managed npm root at ${current.packageDir}`,
);
if (!first.startsWith("@")) {
return first;
}
if (visitedRealPaths.has(packageDirRealPath)) {
const second = parts[index + 2];
return second ? `${first}/${second}` : undefined;
}
return undefined;
}
function findLockPackageVersion(params: {
lockfile: ManagedNpmRootLockfile;
packageName: string;
}): string | undefined {
if (!isRecord(params.lockfile.packages)) {
return undefined;
}
const preferredLocation = `node_modules/${params.packageName}`;
const preferredPackage = params.lockfile.packages[preferredLocation];
if (
isRecord(preferredPackage) &&
!isDevOnlyLockPackage(preferredPackage) &&
!isUnsupportedOptionalLockPackage(preferredPackage)
) {
const preferredVersion = readOptionalString(preferredPackage.version);
if (preferredVersion) {
return preferredVersion;
}
}
return undefined;
}
function collectNpmLockPeerDependencyPins(params: {
lockfile: ManagedNpmRootLockfile;
}): Record<string, string> {
const pins = new Map<string, string>();
const packages = isRecord(params.lockfile.packages) ? params.lockfile.packages : {};
for (const [location, value] of Object.entries(packages).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (
location === "" ||
!isRecord(value) ||
isDevOnlyLockPackage(value) ||
isUnsupportedOptionalLockPackage(value)
) {
continue;
}
visitedRealPaths.add(packageDirRealPath);
if (visitedRealPaths.size > limits.maxDirectories) {
throw new Error(
`managed npm peer dependency scan exceeded max packages (${limits.maxDirectories}) under ${params.npmRoot}`,
);
const packageName = readLockPackageName(location, value);
if (packageName && isManagedNpmRootHostPeerPackageName(packageName)) {
continue;
}
const packageDir = current.packageDir;
const manifest = await readPackageJsonIfExists(packageDir);
if (manifest) {
if (readOptionalString(manifest.name) === "openclaw") {
const peerDependencies = readDependencyRecord(value.peerDependencies);
for (const [peerName, peerRange] of Object.entries(peerDependencies)) {
if (
isManagedNpmRootHostPeerPackageName(peerName) ||
pins.has(peerName) ||
!isSafePackageName(peerName)
) {
continue;
}
const peerDependencies = readDependencyRecord(manifest.peerDependencies);
for (const [peerName, peerRange] of Object.entries(peerDependencies)) {
if (peerName === "openclaw" || pins.has(peerName) || !isSafePackageName(peerName)) {
continue;
}
const installedVersion = await readPackageVersion(
path.join(params.npmRoot, "node_modules", ...peerName.split("/")),
);
if (!installedVersion && isOptionalPeerDependency(manifest, peerName)) {
continue;
}
pins.set(peerName, installedVersion ?? peerRange);
const version = findLockPackageVersion({ lockfile: params.lockfile, packageName: peerName });
if (!version && isOptionalPeerDependency(value, peerName)) {
continue;
}
pins.set(peerName, version ?? peerRange);
}
queue.push(
...(await listNodeModulesPackageDirs(path.join(packageDir, "node_modules"))).map(
(nestedPackageDir) => ({
depth: current.depth + 1,
packageDir: nestedPackageDir,
}),
),
);
}
return Object.fromEntries(
[...pins.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
);
}
async function copyPathIfExists(source: string, destination: string): Promise<void> {
try {
await fs.cp(source, destination, { recursive: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return;
}
throw err;
}
}
function scrubHostPeerFromLockPackage(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
let changed = false;
if (isRecord(value.peerDependencies) && "openclaw" in value.peerDependencies) {
const peerDependencies = { ...value.peerDependencies };
delete peerDependencies.openclaw;
if (Object.keys(peerDependencies).length > 0) {
value.peerDependencies = peerDependencies;
} else {
delete value.peerDependencies;
}
changed = true;
}
if (isRecord(value.peerDependenciesMeta) && "openclaw" in value.peerDependenciesMeta) {
const peerDependenciesMeta = { ...value.peerDependenciesMeta };
delete peerDependenciesMeta.openclaw;
if (Object.keys(peerDependenciesMeta).length > 0) {
value.peerDependenciesMeta = peerDependenciesMeta;
} else {
delete value.peerDependenciesMeta;
}
changed = true;
}
return changed;
}
async function scrubHostPeerFromTempPackageLock(lockPath: string): Promise<void> {
const parsed = await readJsonIfExists<unknown>(lockPath);
if (!isRecord(parsed)) {
return;
}
let changed = false;
if (isRecord(parsed.packages)) {
for (const value of Object.values(parsed.packages)) {
changed = scrubHostPeerFromLockPackage(value) || changed;
}
}
if (isRecord(parsed.dependencies)) {
for (const value of Object.values(parsed.dependencies)) {
changed = scrubHostPeerFromLockPackage(value) || changed;
}
}
if (changed) {
await writeJson(lockPath, parsed, { trailingNewline: true });
}
}
function collectExistingManagedPeerDependencyPins(
dependencies: Record<string, string>,
previousManagedPeerDependencies: string[],
): Record<string, string> {
const pins: Record<string, string> = {};
for (const packageName of previousManagedPeerDependencies) {
const dependencySpec = dependencies[packageName];
if (dependencySpec) {
pins[packageName] = dependencySpec;
}
}
return pins;
}
function isHostPeerResolutionFailure(
result: Awaited<ReturnType<ManagedNpmRootRunCommand>>,
): boolean {
const output = `${result.stdout}\n${result.stderr}`;
return /(^|[^@\w.-])openclaw(?=$|[@\s:,"'])/i.test(output);
}
function createManagedNpmPeerPlanArgs(params?: {
force?: boolean;
legacyPeerDeps?: boolean;
}): string[] {
return [
"npm",
"install",
"--package-lock-only",
...(params?.force ? ["--force"] : []),
...createSafeNpmInstallArgs({
omitDev: true,
omitPeer: true,
legacyPeerDeps: params?.legacyPeerDeps,
loglevel: "error",
ignoreWorkspaces: true,
noAudit: true,
noFund: true,
}).slice(1),
];
}
async function collectNpmResolvedManagedNpmRootPeerDependencyPins(params: {
npmRoot: string;
runCommand?: ManagedNpmRootRunCommand;
timeoutMs?: number;
}): Promise<Record<string, string>> {
const manifest = await readManagedNpmRootManifest(path.join(params.npmRoot, "package.json"));
const dependencies = readDependencyRecord(manifest.dependencies);
const previousManagedPeerDependencies = readManagedPeerDependencyKeys(manifest.openclaw);
const fallbackPeerPins = collectExistingManagedPeerDependencyPins(
dependencies,
previousManagedPeerDependencies,
);
for (const packageName of previousManagedPeerDependencies) {
delete dependencies[packageName];
}
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-managed-peer-plan-"));
try {
delete dependencies.openclaw;
await writeJson(
path.join(tempRoot, "package.json"),
{
...manifest,
private: true,
dependencies,
},
{ trailingNewline: true },
);
await copyPathIfExists(
path.join(params.npmRoot, "package-lock.json"),
path.join(tempRoot, "package-lock.json"),
);
const tempLockPath = path.join(tempRoot, "package-lock.json");
await scrubHostPeerFromTempPackageLock(tempLockPath);
await copyPathIfExists(path.join(params.npmRoot, ".npmrc"), path.join(tempRoot, ".npmrc"));
await copyPathIfExists(
path.join(params.npmRoot, "_openclaw-pack-archives"),
path.join(tempRoot, "_openclaw-pack-archives"),
);
const command = params.runCommand ?? runCommandWithTimeout;
const npmPeerPlanArgs = createManagedNpmPeerPlanArgs({ force: true });
const npmPlanOptions = {
cwd: tempRoot,
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: false,
packageLock: true,
quiet: true,
}),
};
const result = await command(npmPeerPlanArgs, npmPlanOptions);
if (result.code !== 0) {
if (isHostPeerResolutionFailure(result)) {
const hostPeerFallbackArgs = createManagedNpmPeerPlanArgs({
force: true,
legacyPeerDeps: true,
});
const hostPeerFallbackOptions = {
...npmPlanOptions,
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
packageLock: true,
quiet: true,
}),
};
const hostPeerFallbackResult = await command(hostPeerFallbackArgs, hostPeerFallbackOptions);
if (hostPeerFallbackResult.code === 0) {
const lockfile = await readManagedNpmRootManifest(tempLockPath);
return collectNpmLockPeerDependencyPins({ lockfile });
}
}
return fallbackPeerPins;
}
const lockfile = await readManagedNpmRootManifest(tempLockPath);
return collectNpmLockPeerDependencyPins({ lockfile });
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
}
export async function readManagedNpmRootPeerDependencySnapshot(params: {
npmRoot: string;
}): Promise<ManagedNpmRootPeerDependencySnapshot> {
@@ -516,13 +683,19 @@ export async function syncManagedNpmRootPeerDependencies(params: {
npmRoot: string;
managedOverrides?: Record<string, unknown>;
omitUnsupportedManagedOverrides?: boolean;
runCommand?: ManagedNpmRootRunCommand;
timeoutMs?: number;
}): Promise<boolean> {
const manifestPath = path.join(params.npmRoot, "package.json");
const manifest = await readManagedNpmRootManifest(manifestPath);
const dependencies = readDependencyRecord(manifest.dependencies);
const previousManagedPeerDependencies = readManagedPeerDependencyKeys(manifest.openclaw);
const previousManagedPeerDependencySet = new Set(previousManagedPeerDependencies);
const peerPins = await collectManagedNpmRootPeerDependencyPins({ npmRoot: params.npmRoot });
const peerPins = await collectNpmResolvedManagedNpmRootPeerDependencyPins({
npmRoot: params.npmRoot,
runCommand: params.runCommand,
timeoutMs: params.timeoutMs,
});
const nextDependencies = { ...dependencies };
for (const packageName of previousManagedPeerDependencies) {
if (!Object.hasOwn(peerPins, packageName)) {
@@ -578,12 +751,22 @@ export async function syncManagedNpmRootPeerDependencies(params: {
export async function repairManagedNpmRootOpenClawPeer(params: {
npmRoot: string;
packageRoot?: string | null;
timeoutMs?: number;
logger?: ManagedNpmRootLogger;
runCommand?: ManagedNpmRootRunCommand;
}): Promise<boolean> {
await fs.mkdir(params.npmRoot, { recursive: true });
if (
await managedNpmRootOpenClawPackageIsActiveHost({
npmRoot: params.npmRoot,
packageRoot: params.packageRoot,
})
) {
return false;
}
const manifestPath = path.join(params.npmRoot, "package.json");
const manifest = await readManagedNpmRootManifest(manifestPath);
const dependencies = readDependencyRecord(manifest.dependencies);
@@ -640,6 +823,30 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
return true;
}
async function managedNpmRootOpenClawPackageIsActiveHost(params: {
npmRoot: string;
packageRoot?: string | null;
}): Promise<boolean> {
const packageRoot =
params.packageRoot === undefined
? resolveOpenClawPackageRootSync({
argv1: process.argv[1],
moduleUrl: import.meta.url,
cwd: process.cwd(),
})
: params.packageRoot;
if (!packageRoot) {
return false;
}
const managedOpenClawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
const [hostPackageRoot, managedPackageRoot] = await Promise.all([
realpathIfExists(packageRoot),
realpathIfExists(managedOpenClawPackageDir),
]);
return hostPackageRoot !== null && hostPackageRoot === managedPackageRoot;
}
async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<boolean> {
const lockPath = path.join(npmRoot, "package-lock.json");
try {
@@ -666,6 +873,17 @@ async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<b
}
}
async function realpathIfExists(filePath: string): Promise<string | null> {
try {
return await fs.realpath(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
}
}
async function pathExists(filePath: string): Promise<boolean> {
return await fs
.lstat(filePath)
+137 -1
View File
@@ -10,7 +10,9 @@ import { installPluginFromNpmSpec } from "./install.js";
type PackedVersion = {
archive: Buffer;
dependencies?: Record<string, string>;
integrity: string;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
shasum: string;
@@ -46,7 +48,9 @@ async function makeTempDir(label: string): Promise<string> {
}
async function packPlugin(params: {
dependencies?: Record<string, string>;
packageName: string;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
pluginId: string;
@@ -70,6 +74,10 @@ async function packPlugin(params: {
version: params.version,
type: "module",
openclaw: { extensions: ["./dist/index.js"] },
...(params.dependencies ? { dependencies: params.dependencies } : {}),
...(params.optionalDependencies
? { optionalDependencies: params.optionalDependencies }
: {}),
...(params.peerDependencies
? {
peerDependencies: params.peerDependencies,
@@ -114,7 +122,9 @@ async function packPlugin(params: {
const archive = await fs.readFile(path.join(params.rootDir, tarballName));
return {
archive,
...(params.dependencies ? { dependencies: params.dependencies } : {}),
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
...(params.optionalDependencies ? { optionalDependencies: params.optionalDependencies } : {}),
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
@@ -157,6 +167,10 @@ async function startStaticRegistry(
{
name: pkg.packageName,
version,
...(entry.dependencies ? { dependencies: entry.dependencies } : {}),
...(entry.optionalDependencies
? { optionalDependencies: entry.optionalDependencies }
: {}),
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
...(entry.peerDependenciesMeta
? { peerDependenciesMeta: entry.peerDependenciesMeta }
@@ -444,6 +458,82 @@ describe("installPluginFromNpmSpec e2e", () => {
).resolves.toBeTruthy();
});
it("plans peers from installed optional dependencies", async () => {
const rootDir = await makeTempDir("npm-plugin-optional-peer-e2e");
const npmRoot = path.join(rootDir, "managed-npm");
const pluginWithOptionalDependency = `optional-owner-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const optionalDependency = `optional-dep-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const runtimePeer = `optional-peer-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const registry = await startStaticRegistry([
{
packageName: pluginWithOptionalDependency,
latest: "1.0.0",
versions: [
await packPlugin({
packageName: pluginWithOptionalDependency,
optionalDependencies: { [optionalDependency]: "1.0.0" },
pluginId: pluginWithOptionalDependency,
version: "1.0.0",
rootDir,
}),
],
},
{
packageName: optionalDependency,
latest: "1.0.0",
versions: [
await packPlugin({
packageName: optionalDependency,
peerDependencies: { [runtimePeer]: "^1.0.0" },
peerDependenciesMeta: {},
pluginId: optionalDependency,
version: "1.0.0",
rootDir,
}),
],
},
{
packageName: runtimePeer,
latest: "1.0.0",
versions: [
await packPlugin({
packageName: runtimePeer,
pluginId: runtimePeer,
version: "1.0.0",
rootDir,
}),
],
},
]);
process.env.NPM_CONFIG_REGISTRY = registry;
process.env.npm_config_registry = registry;
const result = await installPluginFromNpmSpec({
spec: `${pluginWithOptionalDependency}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
timeoutMs: 120_000,
});
if (!result.ok) {
throw new Error(result.error);
}
await expect(
fs.lstat(path.join(npmRoot, "node_modules", optionalDependency, "package.json")),
).resolves.toBeTruthy();
await expect(
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
).resolves.toBeTruthy();
const rootManifest = JSON.parse(
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
openclaw?: { managedPeerDependencies?: string[] };
};
expect(rootManifest.dependencies?.[runtimePeer]).toBe("1.0.0");
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).toContain(runtimePeer);
});
it("repairs pre-existing peer dependencies during later installs", async () => {
const rootDir = await makeTempDir("npm-plugin-repaired-peer-scan-e2e");
const npmRoot = path.join(rootDir, "managed-npm");
@@ -548,7 +638,7 @@ describe("installPluginFromNpmSpec e2e", () => {
openclaw?: { managedPeerDependencies?: string[] };
};
expect(rootManifest.dependencies?.[laterPlugin]).toBe("1.0.0");
expect(rootManifest.dependencies?.[runtimePeer]).toBe("^1.0.0");
expect(rootManifest.dependencies?.[runtimePeer]).toBe("1.0.0");
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).toContain(runtimePeer);
});
@@ -692,6 +782,52 @@ describe("installPluginFromNpmSpec e2e", () => {
).rejects.toHaveProperty("code", "ENOENT");
});
it("falls back to the legacy install path when npm cannot plan third-party peers", async () => {
const rootDir = await makeTempDir("npm-plugin-peer-plan-fallback-e2e");
const npmRoot = path.join(rootDir, "managed-npm");
const blockedPlugin = `missing-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const missingPeer = `missing-peer-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const registry = await startStaticRegistry([
{
packageName: blockedPlugin,
latest: "1.0.0",
versions: [
await packPlugin({
packageName: blockedPlugin,
peerDependencies: { [missingPeer]: "^1.0.0" },
peerDependenciesMeta: {},
pluginId: blockedPlugin,
version: "1.0.0",
rootDir,
}),
],
},
]);
process.env.NPM_CONFIG_REGISTRY = registry;
process.env.npm_config_registry = registry;
const result = await installPluginFromNpmSpec({
spec: `${blockedPlugin}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
timeoutMs: 120_000,
});
expect(result.ok).toBe(true);
const rootManifest = JSON.parse(
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
openclaw?: { managedPeerDependencies?: string[] };
};
expect(rootManifest.dependencies?.[blockedPlugin]).toBe("1.0.0");
expect(rootManifest.dependencies?.[missingPeer]).toBeUndefined();
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(missingPeer);
await expect(
fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")),
).resolves.toBeTruthy();
});
it("does not take ownership of an existing root dependency observed as a peer", async () => {
const rootDir = await makeTempDir("npm-plugin-peer-existing-root-e2e");
const npmRoot = path.join(rootDir, "managed-npm");
+171 -40
View File
@@ -57,10 +57,20 @@ function resolveManagedFileDependency(npmRoot: string, dependencySpec: string):
return path.isAbsolute(rawPath) ? rawPath : path.resolve(npmRoot, rawPath);
}
function isNpmInstallCommand(argv: unknown): argv is string[] {
return Array.isArray(argv) && argv[0] === "npm" && argv[1] === "install";
}
function isNpmPeerPlannerInstallCommand(argv: unknown): argv is string[] {
return isNpmInstallCommand(argv) && argv.includes("--package-lock-only");
}
function isManagedNpmInstallCommand(argv: unknown): argv is string[] {
return isNpmInstallCommand(argv) && !isNpmPeerPlannerInstallCommand(argv);
}
function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string }) {
const installCalls = params.calls.filter(
(call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install",
);
const installCalls = params.calls.filter((call) => isManagedNpmInstallCommand(call[0]));
expect(installCalls).toHaveLength(1);
expect((installCalls[0]?.[1] as { cwd?: unknown } | undefined)?.cwd).toBe(params.npmRoot);
expect(installCalls[0]?.[0]).toEqual([
@@ -291,10 +301,29 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
JSON.stringify(versionsPackage.versions ?? [versionsPackage.version]),
);
}
if (argv[0] === "npm" && argv[1] === "install") {
if (isNpmPeerPlannerInstallCommand(argv)) {
const npmRoot = options?.cwd;
if (!npmRoot) {
throw new Error(`unexpected npm install command: ${argv.join(" ")}`);
throw new Error(`unexpected npm peer planner command: ${argv.join(" ")}`);
}
const manifest = JSON.parse(
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
};
writeNpmRootPackageLock({
npmRoot,
dependencies: manifest.dependencies ?? {},
packages: Object.keys(manifest.dependencies ?? {})
.map((packageName) => packagesByName.get(packageName))
.filter((pkg): pkg is MockNpmPackage => Boolean(pkg)),
});
return successfulSpawn();
}
if (isManagedNpmInstallCommand(argv)) {
const npmRoot = options?.cwd;
if (!npmRoot) {
throw new Error(`unexpected npm install command: ${(argv as string[]).join(" ")}`);
}
const manifest = JSON.parse(
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
@@ -353,11 +382,11 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
return successfulSpawn();
}
if (argv[0] === "npm" && argv[1] === "uninstall") {
const packageName = argv.at(-1);
const packageName = (argv as string[]).at(-1);
if (packageName === "openclaw") {
const npmRoot = options?.cwd;
if (!npmRoot) {
throw new Error(`unexpected npm uninstall command: ${argv.join(" ")}`);
throw new Error(`unexpected npm uninstall command: ${(argv as string[]).join(" ")}`);
}
fs.rmSync(path.join(npmRoot, "node_modules", "openclaw"), {
recursive: true,
@@ -375,7 +404,7 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
});
return successfulSpawn();
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`);
},
);
}
@@ -949,6 +978,89 @@ describe("installPluginFromNpmSpec", () => {
expect(lockfile.dependencies?.openclaw).toBeUndefined();
});
it("preserves the active host openclaw runtime package during npm plugin installs", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
const hostPackageRoot = path.join(npmRoot, "node_modules", "openclaw");
fs.mkdirSync(hostPackageRoot, { recursive: true });
fs.writeFileSync(
path.join(npmRoot, "package.json"),
JSON.stringify(
{
private: true,
dependencies: {
openclaw: "2026.5.12-beta.6",
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(npmRoot, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: {
openclaw: "2026.5.12-beta.6",
},
},
"node_modules/openclaw": {
version: "2026.5.12-beta.6",
},
},
},
null,
2,
)}\n`,
"utf-8",
);
fs.writeFileSync(
path.join(hostPackageRoot, "package.json"),
JSON.stringify({
name: "openclaw",
version: "2026.5.12-beta.6",
}),
"utf-8",
);
resolveOpenClawPackageRootSyncMock.mockReturnValue(hostPackageRoot);
mockNpmViewAndInstall({
spec: "@xdarkicex/openclaw-memory-libravdb@1.4.69",
packageName: "@xdarkicex/openclaw-memory-libravdb",
version: "1.4.69",
pluginId: "libravdb-memory",
npmRoot,
expectedDependencySpec: "1.4.69",
});
const result = await installPluginFromNpmSpec({
spec: "@xdarkicex/openclaw-memory-libravdb@1.4.69",
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(true);
const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
expect(manifest.dependencies?.openclaw).toBe("2026.5.12-beta.6");
expect(manifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBe("1.4.69");
expect(fs.existsSync(hostPackageRoot)).toBe(true);
expect(
runCommandWithTimeoutMock.mock.calls.some(
([argv]) =>
Array.isArray(argv) &&
argv[0] === "npm" &&
argv[1] === "uninstall" &&
argv.includes("openclaw"),
),
).toBe(false);
});
it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => {
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const warnings: string[] = [];
@@ -1000,38 +1112,57 @@ describe("installPluginFromNpmSpec", () => {
"utf8",
);
fs.symlinkSync(suiteTempRootTracker.makeTempDir(), peerLink, "junction");
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/voice-call@0.0.1"))) {
return successfulSpawn(
JSON.stringify({
name: "@openclaw/voice-call",
version: "0.0.1",
dist: {
integrity: "sha512-plugin-test",
shasum: "pluginshasum",
},
}),
);
}
if (argv[0] === "npm" && argv[1] === "install") {
fs.rmSync(peerLink, { recursive: true, force: true });
return {
code: 1,
stdout: "",
stderr: "registry unavailable",
signal: null,
killed: false,
termination: "exit" as const,
};
}
if (argv[0] === "npm" && argv[1] === "uninstall") {
if (!argv.includes("--legacy-peer-deps")) {
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
runCommandWithTimeoutMock.mockImplementation(
async (argv: string[], options?: { cwd?: string }) => {
if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/voice-call@0.0.1"))) {
return successfulSpawn(
JSON.stringify({
name: "@openclaw/voice-call",
version: "0.0.1",
dist: {
integrity: "sha512-plugin-test",
shasum: "pluginshasum",
},
}),
);
}
return successfulSpawn("");
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
});
if (isNpmPeerPlannerInstallCommand(argv)) {
const npmRoot = options?.cwd;
if (!npmRoot) {
throw new Error(`unexpected npm peer planner command: ${argv.join(" ")}`);
}
const manifest = JSON.parse(
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
};
writeNpmRootPackageLock({
npmRoot,
dependencies: manifest.dependencies ?? {},
packages: [],
});
return successfulSpawn();
}
if (isManagedNpmInstallCommand(argv)) {
fs.rmSync(peerLink, { recursive: true, force: true });
return {
code: 1,
stdout: "",
stderr: "registry unavailable",
signal: null,
killed: false,
termination: "exit" as const,
};
}
if (argv[0] === "npm" && argv[1] === "uninstall") {
if (!(argv as string[]).includes("--legacy-peer-deps")) {
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
}
return successfulSpawn("");
}
throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`);
},
);
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call@0.0.1",
@@ -1087,7 +1218,7 @@ describe("installPluginFromNpmSpec", () => {
let installAttempts = 0;
runCommandWithTimeoutMock.mockImplementation(
async (argv: string[], options?: { cwd?: string }) => {
if (argv[0] === "npm" && argv[1] === "install") {
if (isManagedNpmInstallCommand(argv)) {
installAttempts += 1;
const manifest = JSON.parse(
fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"),
+51 -8
View File
@@ -635,13 +635,47 @@ async function installPluginFromManagedNpmRoot(
const rollbackPeerDependencySnapshot = await readManagedNpmRootPeerDependencySnapshot({
npmRoot,
});
const rollbackFailedManagedNpmInstall = async (error: string): Promise<InstallPluginResult> => {
await rollbackManagedNpmPluginInstall({
npmRoot,
packageName: params.packageName,
targetDir: installRoot,
timeoutMs,
logger,
peerDependencySnapshot: rollbackPeerDependencySnapshot,
});
return { ok: false, error };
};
const syncManagedPeerDependenciesForInstall = async (options?: {
omitUnsupportedManagedOverrides?: boolean;
}): Promise<{ ok: true; changed: boolean } | { ok: false; error: string }> => {
try {
return {
ok: true,
changed: await syncManagedNpmRootPeerDependencies({
npmRoot,
managedOverrides,
omitUnsupportedManagedOverrides: options?.omitUnsupportedManagedOverrides,
timeoutMs,
}),
};
} catch (error) {
return {
ok: false,
error: `npm peer dependency planning failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
};
await upsertManagedNpmRootDependency({
npmRoot,
packageName: params.packageName,
dependencySpec: params.dependencySpec,
managedOverrides,
});
await syncManagedNpmRootPeerDependencies({ npmRoot, managedOverrides });
const initialPeerSync = await syncManagedPeerDependenciesForInstall();
if (!initialPeerSync.ok) {
return await rollbackFailedManagedNpmInstall(initialPeerSync.error);
}
const npmInstallArgs = [
"npm",
...createSafeNpmInstallArgs({
@@ -676,6 +710,12 @@ async function installPluginFromManagedNpmRoot(
managedOverrides,
omitUnsupportedManagedOverrides: true,
});
const aliasRetryPeerSync = await syncManagedPeerDependenciesForInstall({
omitUnsupportedManagedOverrides: true,
});
if (!aliasRetryPeerSync.ok) {
return await rollbackFailedManagedNpmInstall(aliasRetryPeerSync.error);
}
install = await runCommandWithTimeout(npmInstallArgs, npmInstallOptions);
}
if (install.code !== 0) {
@@ -694,11 +734,13 @@ async function installPluginFromManagedNpmRoot(
}
let settledManagedPeerDependencies = false;
for (let peerSyncPass = 0; peerSyncPass < 10; peerSyncPass += 1) {
const syncedPeerDependencies = await syncManagedNpmRootPeerDependencies({
npmRoot,
managedOverrides,
const peerSync = await syncManagedPeerDependenciesForInstall({
omitUnsupportedManagedOverrides,
});
if (!peerSync.ok) {
return await rollbackFailedManagedNpmInstall(peerSync.error);
}
const syncedPeerDependencies = peerSync.changed;
if (!syncedPeerDependencies) {
settledManagedPeerDependencies = true;
break;
@@ -720,12 +762,13 @@ async function installPluginFromManagedNpmRoot(
}
}
if (!settledManagedPeerDependencies) {
const syncedPeerDependencies = await syncManagedNpmRootPeerDependencies({
npmRoot,
managedOverrides,
const peerSync = await syncManagedPeerDependenciesForInstall({
omitUnsupportedManagedOverrides,
});
settledManagedPeerDependencies = !syncedPeerDependencies;
if (!peerSync.ok) {
return await rollbackFailedManagedNpmInstall(peerSync.error);
}
settledManagedPeerDependencies = !peerSync.changed;
}
if (!settledManagedPeerDependencies) {
await rollbackManagedNpmPluginInstall({