Skip to content

Commit f4cb203

Browse files
committed
fix: harden managed plugin peer recovery
1 parent 6e5042c commit f4cb203

3 files changed

Lines changed: 337 additions & 6 deletions

File tree

src/infra/npm-managed-root.ts

Lines changed: 148 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ type ManagedNpmRootOpenClawMetadata = {
2929
[key: string]: unknown;
3030
};
3131

32+
export type ManagedNpmRootPeerDependencySnapshot = {
33+
dependencies: Record<string, string>;
34+
managedPeerDependencies: string[];
35+
};
36+
3237
export type ManagedNpmRootInstalledDependency = {
3338
version?: string;
3439
integrity?: string;
@@ -47,6 +52,16 @@ type ManagedNpmRootLogger = {
4752

4853
type ManagedNpmRootRunCommand = typeof runCommandWithTimeout;
4954

55+
type ManagedNpmPeerTraversalLimits = {
56+
maxDepth: number;
57+
maxDirectories: number;
58+
};
59+
60+
const DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS: ManagedNpmPeerTraversalLimits = {
61+
maxDepth: 64,
62+
maxDirectories: 10_000,
63+
};
64+
5065
function isRecord(value: unknown): value is Record<string, unknown> {
5166
return typeof value === "object" && value !== null && !Array.isArray(value);
5267
}
@@ -68,6 +83,47 @@ function readDependencyRecord(value: unknown): Record<string, string> {
6883
return dependencies;
6984
}
7085

86+
function readPositiveIntegerEnv(name: string, fallback: number): number {
87+
const rawValue = process.env[name];
88+
if (!rawValue) {
89+
return fallback;
90+
}
91+
const parsedValue = Number.parseInt(rawValue, 10);
92+
return Number.isFinite(parsedValue) && parsedValue >= 1 ? parsedValue : fallback;
93+
}
94+
95+
function resolveManagedNpmPeerTraversalLimits(): ManagedNpmPeerTraversalLimits {
96+
return {
97+
maxDepth: readPositiveIntegerEnv(
98+
"OPENCLAW_INSTALL_SCAN_MAX_DEPTH",
99+
DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS.maxDepth,
100+
),
101+
maxDirectories: readPositiveIntegerEnv(
102+
"OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES",
103+
DEFAULT_MANAGED_NPM_PEER_TRAVERSAL_LIMITS.maxDirectories,
104+
),
105+
};
106+
}
107+
108+
function isSamePathOrInside(parentPath: string, candidatePath: string): boolean {
109+
const relative = path.relative(parentPath, candidatePath);
110+
return (
111+
relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
112+
);
113+
}
114+
115+
function isSafePackageName(name: string): boolean {
116+
if (name.startsWith("@")) {
117+
const parts = name.split("/");
118+
return (
119+
parts.length === 2 && parts.every((part) => part.length > 0 && part !== "." && part !== "..")
120+
);
121+
}
122+
return (
123+
name.length > 0 && !name.includes("/") && !name.includes("\\") && name !== "." && name !== ".."
124+
);
125+
}
126+
71127
function readOverrideRecord(value: unknown): Record<string, unknown> {
72128
if (!isRecord(value)) {
73129
return {};
@@ -301,7 +357,7 @@ async function listNodeModulesPackageDirs(nodeModulesDir: string): Promise<strin
301357
}
302358
const packageDirs: string[] = [];
303359
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
304-
if (entry.name === ".bin" || entry.name.startsWith(".")) {
360+
if (entry.name === ".bin" || entry.name === "openclaw" || entry.name.startsWith(".")) {
305361
continue;
306362
}
307363
const entryPath = path.join(nodeModulesDir, entry.name);
@@ -338,20 +394,50 @@ async function collectManagedNpmRootPeerDependencyPins(params: {
338394
npmRoot: string;
339395
}): Promise<Record<string, string>> {
340396
const pins = new Map<string, string>();
341-
const queue = await listNodeModulesPackageDirs(path.join(params.npmRoot, "node_modules"));
397+
const limits = resolveManagedNpmPeerTraversalLimits();
398+
const boundaryRealPath = await fs
399+
.realpath(params.npmRoot)
400+
.catch(() => path.resolve(params.npmRoot));
401+
const queue = (await listNodeModulesPackageDirs(path.join(params.npmRoot, "node_modules"))).map(
402+
(packageDir) => ({ depth: 0, packageDir }),
403+
);
404+
const visitedRealPaths = new Set<string>();
342405
for (let index = 0; index < queue.length; index += 1) {
343-
const packageDir = queue[index];
344-
if (!packageDir) {
406+
const current = queue[index];
407+
if (!current) {
408+
continue;
409+
}
410+
if (current.depth > limits.maxDepth) {
411+
throw new Error(
412+
`managed npm peer dependency scan exceeded max depth (${limits.maxDepth}) at ${current.packageDir}`,
413+
);
414+
}
415+
const packageDirRealPath = await fs
416+
.realpath(current.packageDir)
417+
.catch(() => path.resolve(current.packageDir));
418+
if (!isSamePathOrInside(boundaryRealPath, packageDirRealPath)) {
419+
throw new Error(
420+
`managed npm peer dependency scan found package outside managed npm root at ${current.packageDir}`,
421+
);
422+
}
423+
if (visitedRealPaths.has(packageDirRealPath)) {
345424
continue;
346425
}
426+
visitedRealPaths.add(packageDirRealPath);
427+
if (visitedRealPaths.size > limits.maxDirectories) {
428+
throw new Error(
429+
`managed npm peer dependency scan exceeded max packages (${limits.maxDirectories}) under ${params.npmRoot}`,
430+
);
431+
}
432+
const packageDir = current.packageDir;
347433
const manifest = await readPackageJsonIfExists(packageDir);
348434
if (manifest) {
349435
if (readOptionalString(manifest.name) === "openclaw") {
350436
continue;
351437
}
352438
const peerDependencies = readDependencyRecord(manifest.peerDependencies);
353439
for (const [peerName, peerRange] of Object.entries(peerDependencies)) {
354-
if (peerName === "openclaw" || pins.has(peerName)) {
440+
if (peerName === "openclaw" || pins.has(peerName) || !isSafePackageName(peerName)) {
355441
continue;
356442
}
357443
const installedVersion = await readPackageVersion(
@@ -363,13 +449,69 @@ async function collectManagedNpmRootPeerDependencyPins(params: {
363449
pins.set(peerName, installedVersion ?? peerRange);
364450
}
365451
}
366-
queue.push(...(await listNodeModulesPackageDirs(path.join(packageDir, "node_modules"))));
452+
queue.push(
453+
...(await listNodeModulesPackageDirs(path.join(packageDir, "node_modules"))).map(
454+
(nestedPackageDir) => ({
455+
depth: current.depth + 1,
456+
packageDir: nestedPackageDir,
457+
}),
458+
),
459+
);
367460
}
368461
return Object.fromEntries(
369462
[...pins.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
370463
);
371464
}
372465

466+
export async function readManagedNpmRootPeerDependencySnapshot(params: {
467+
npmRoot: string;
468+
}): Promise<ManagedNpmRootPeerDependencySnapshot> {
469+
const manifest = await readManagedNpmRootManifest(path.join(params.npmRoot, "package.json"));
470+
const dependencies = readDependencyRecord(manifest.dependencies);
471+
const managedPeerDependencies = readManagedPeerDependencyKeys(manifest.openclaw).toSorted();
472+
const dependencySnapshot: Record<string, string> = {};
473+
for (const packageName of managedPeerDependencies) {
474+
const dependencySpec = dependencies[packageName];
475+
if (dependencySpec) {
476+
dependencySnapshot[packageName] = dependencySpec;
477+
}
478+
}
479+
return {
480+
dependencies: dependencySnapshot,
481+
managedPeerDependencies,
482+
};
483+
}
484+
485+
export async function restoreManagedNpmRootPeerDependencySnapshot(params: {
486+
npmRoot: string;
487+
snapshot: ManagedNpmRootPeerDependencySnapshot;
488+
}): Promise<void> {
489+
const manifestPath = path.join(params.npmRoot, "package.json");
490+
const manifest = await readManagedNpmRootManifest(manifestPath);
491+
const dependencies = readDependencyRecord(manifest.dependencies);
492+
for (const packageName of readManagedPeerDependencyKeys(manifest.openclaw)) {
493+
delete dependencies[packageName];
494+
}
495+
Object.assign(dependencies, params.snapshot.dependencies);
496+
const managedOverrideKeys = readManagedOverrideKeys(manifest.openclaw).toSorted();
497+
const openclawMetadata = buildManagedOpenClawMetadata({
498+
current: manifest.openclaw,
499+
managedOverrideKeys,
500+
managedPeerDependencyKeys: params.snapshot.managedPeerDependencies.toSorted(),
501+
});
502+
const next: ManagedNpmRootManifest = {
503+
...manifest,
504+
private: true,
505+
dependencies,
506+
};
507+
if (openclawMetadata) {
508+
next.openclaw = openclawMetadata;
509+
} else {
510+
delete next.openclaw;
511+
}
512+
await writeJson(manifestPath, next, { trailingNewline: true });
513+
}
514+
373515
export async function syncManagedNpmRootPeerDependencies(params: {
374516
npmRoot: string;
375517
managedOverrides?: Record<string, unknown>;

src/plugins/install.npm-spec.e2e.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,146 @@ describe("installPluginFromNpmSpec e2e", () => {
540540
).resolves.toBeTruthy();
541541
});
542542

543+
it("bounds peer dependency discovery across repeated nested package realpaths", async () => {
544+
const rootDir = await makeTempDir("npm-plugin-peer-cycle-e2e");
545+
const npmRoot = path.join(rootDir, "managed-npm");
546+
const existingPlugin = `existing-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
547+
const laterPlugin = `later-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
548+
const registry = await startStaticRegistry([
549+
{
550+
packageName: existingPlugin,
551+
latest: "1.0.0",
552+
versions: [
553+
await packPlugin({
554+
packageName: existingPlugin,
555+
pluginId: existingPlugin,
556+
version: "1.0.0",
557+
rootDir,
558+
}),
559+
],
560+
},
561+
{
562+
packageName: laterPlugin,
563+
latest: "1.0.0",
564+
versions: [
565+
await packPlugin({
566+
packageName: laterPlugin,
567+
pluginId: laterPlugin,
568+
version: "1.0.0",
569+
rootDir,
570+
}),
571+
],
572+
},
573+
]);
574+
process.env.NPM_CONFIG_REGISTRY = registry;
575+
process.env.npm_config_registry = registry;
576+
577+
await fs.mkdir(npmRoot, { recursive: true });
578+
await fs.writeFile(
579+
path.join(npmRoot, "package.json"),
580+
`${JSON.stringify(
581+
{
582+
private: true,
583+
dependencies: { [existingPlugin]: "1.0.0" },
584+
},
585+
null,
586+
2,
587+
)}\n`,
588+
"utf8",
589+
);
590+
await execFileAsync(
591+
"npm",
592+
[
593+
"install",
594+
"--omit=dev",
595+
"--omit=peer",
596+
"--legacy-peer-deps",
597+
"--loglevel=error",
598+
"--ignore-scripts",
599+
"--no-audit",
600+
"--no-fund",
601+
],
602+
{ cwd: npmRoot },
603+
);
604+
const existingPluginDir = path.join(npmRoot, "node_modules", existingPlugin);
605+
await fs.mkdir(path.join(existingPluginDir, "node_modules"), { recursive: true });
606+
await fs.symlink(existingPluginDir, path.join(existingPluginDir, "node_modules", "self"));
607+
608+
const later = await installPluginFromNpmSpec({
609+
spec: `${laterPlugin}@1.0.0`,
610+
npmDir: npmRoot,
611+
logger: { info: () => {}, warn: () => {} },
612+
timeoutMs: 120_000,
613+
});
614+
615+
expect(later.ok).toBe(true);
616+
await expect(
617+
fs.lstat(path.join(npmRoot, "node_modules", laterPlugin, "package.json")),
618+
).resolves.toBeTruthy();
619+
});
620+
621+
it("rolls back managed peer dependencies added before a failed install scan", async () => {
622+
const rootDir = await makeTempDir("npm-plugin-peer-rollback-e2e");
623+
const npmRoot = path.join(rootDir, "managed-npm");
624+
const blockedPlugin = `blocked-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
625+
const runtimePeer = `runtime-peer-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
626+
const registry = await startStaticRegistry([
627+
{
628+
packageName: blockedPlugin,
629+
latest: "1.0.0",
630+
versions: [
631+
await packPlugin({
632+
packageName: blockedPlugin,
633+
peerDependencies: { [runtimePeer]: "^1.0.0" },
634+
peerDependenciesMeta: {},
635+
pluginId: blockedPlugin,
636+
version: "1.0.0",
637+
rootDir,
638+
}),
639+
],
640+
},
641+
{
642+
packageName: runtimePeer,
643+
latest: "1.0.0",
644+
versions: [
645+
await packPlugin({
646+
indexJs: "eval('1');\n",
647+
packageName: runtimePeer,
648+
pluginId: runtimePeer,
649+
version: "1.0.0",
650+
rootDir,
651+
}),
652+
],
653+
},
654+
]);
655+
process.env.NPM_CONFIG_REGISTRY = registry;
656+
process.env.npm_config_registry = registry;
657+
658+
const result = await installPluginFromNpmSpec({
659+
spec: `${blockedPlugin}@1.0.0`,
660+
npmDir: npmRoot,
661+
logger: { info: () => {}, warn: () => {} },
662+
timeoutMs: 120_000,
663+
});
664+
665+
expect(result.ok).toBe(false);
666+
const rootManifest = JSON.parse(
667+
await fs.readFile(path.join(npmRoot, "package.json"), "utf8"),
668+
) as {
669+
dependencies?: Record<string, string>;
670+
openclaw?: { managedPeerDependencies?: string[] };
671+
};
672+
expect(rootManifest.dependencies?.[blockedPlugin]).toBeUndefined();
673+
expect(rootManifest.dependencies?.[runtimePeer]).toBeUndefined();
674+
expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(runtimePeer);
675+
await expect(
676+
fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")),
677+
).rejects.toHaveProperty("code", "ENOENT");
678+
await expect(
679+
fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")),
680+
).rejects.toHaveProperty("code", "ENOENT");
681+
});
682+
543683
it("scrubs host peers when installing beside an existing host-peer plugin", async () => {
544684
const rootDir = await makeTempDir("npm-plugin-sibling-peer-e2e");
545685
const npmRoot = path.join(rootDir, "managed-npm");

0 commit comments

Comments
 (0)