Skip to content

Commit e536a86

Browse files
committed
fix(plugins): prevent managed npm peer materialization
1 parent 5468e9b commit e536a86

4 files changed

Lines changed: 128 additions & 26 deletions

File tree

src/infra/safe-package-install.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ describe("safe npm install helpers", () => {
66
expect(
77
createSafeNpmInstallArgs({
88
omitDev: true,
9+
omitPeer: true,
910
ignoreWorkspaces: true,
1011
loglevel: "error",
1112
noAudit: true,
@@ -14,6 +15,7 @@ describe("safe npm install helpers", () => {
1415
).toEqual([
1516
"install",
1617
"--omit=dev",
18+
"--omit=peer",
1719
"--loglevel=error",
1820
"--ignore-scripts",
1921
"--workspaces=false",

src/infra/safe-package-install.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type SafeNpmInstallArgsOptions = {
1414
noAudit?: boolean;
1515
noFund?: boolean;
1616
omitDev?: boolean;
17+
omitPeer?: boolean;
1718
};
1819

1920
export function createSafeNpmInstallEnv(
@@ -46,6 +47,7 @@ export function createSafeNpmInstallArgs(options: SafeNpmInstallArgsOptions = {}
4647
return [
4748
"install",
4849
...(options.omitDev ? ["--omit=dev"] : []),
50+
...(options.omitPeer ? ["--omit=peer"] : []),
4951
...(options.loglevel ? [`--loglevel=${options.loglevel}`] : []),
5052
"--ignore-scripts",
5153
...(options.ignoreWorkspaces ? ["--workspaces=false"] : []),

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string
5454
"npm",
5555
"install",
5656
"--omit=dev",
57+
"--omit=peer",
5758
"--loglevel=error",
5859
"--ignore-scripts",
5960
"--no-audit",
@@ -145,6 +146,7 @@ type MockNpmPackage = {
145146
versions?: string[];
146147
installedVersion?: string;
147148
installedIntegrity?: string;
149+
materializesRootOpenClaw?: boolean;
148150
skipLockfileEntry?: boolean;
149151
};
150152

@@ -166,6 +168,11 @@ function writeNpmRootPackageLock(params: {
166168
version: pkg.installedVersion ?? pkg.version,
167169
integrity: pkg.installedIntegrity ?? pkg.integrity ?? "sha512-plugin-test",
168170
};
171+
if (pkg.materializesRootOpenClaw) {
172+
lockPackages["node_modules/openclaw"] = {
173+
version: "2026.5.3",
174+
};
175+
}
169176
}
170177
fs.writeFileSync(
171178
path.join(params.npmRoot, "package-lock.json"),
@@ -190,6 +197,7 @@ function mockNpmViewAndInstall(params: {
190197
versions?: string[];
191198
installedVersion?: string;
192199
installedIntegrity?: string;
200+
materializesRootOpenClaw?: boolean;
193201
skipLockfileEntry?: boolean;
194202
}) {
195203
mockNpmViewAndInstallMany([params]);
@@ -255,6 +263,15 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
255263
...pkg,
256264
version: pkg.installedVersion ?? pkg.version,
257265
});
266+
if (pkg.materializesRootOpenClaw) {
267+
const openclawRoot = path.join(npmRoot, "node_modules", "openclaw");
268+
fs.mkdirSync(openclawRoot, { recursive: true });
269+
fs.writeFileSync(
270+
path.join(openclawRoot, "package.json"),
271+
JSON.stringify({ name: "openclaw", version: "2026.5.3" }),
272+
"utf-8",
273+
);
274+
}
258275
installedPackages.push(pkg);
259276
}
260277
writeNpmRootPackageLock({
@@ -553,6 +570,39 @@ describe("installPluginFromNpmSpec", () => {
553570
},
554571
);
555572

573+
it.runIf(process.platform !== "win32")(
574+
"repairs root openclaw materialized by npm peer handling and links the host peer",
575+
async () => {
576+
const stateDir = suiteTempRootTracker.makeTempDir();
577+
const npmRoot = path.join(stateDir, "npm");
578+
579+
mockNpmViewAndInstall({
580+
spec: "peer-plugin@1.0.0",
581+
packageName: "peer-plugin",
582+
version: "1.0.0",
583+
pluginId: "peer-plugin",
584+
npmRoot,
585+
peerDependencies: { openclaw: "^2026.0.0" },
586+
materializesRootOpenClaw: true,
587+
});
588+
589+
const result = await installPluginFromNpmSpec({
590+
spec: "peer-plugin@1.0.0",
591+
npmDir: npmRoot,
592+
trustedManagedNpmRoot: true,
593+
logger: { info: () => {}, warn: () => {} },
594+
});
595+
596+
expect(result.ok).toBe(true);
597+
expect(fs.existsSync(path.join(npmRoot, "node_modules", "openclaw"))).toBe(false);
598+
expect(
599+
fs
600+
.lstatSync(path.join(npmRoot, "node_modules", "peer-plugin", "node_modules", "openclaw"))
601+
.isSymbolicLink(),
602+
).toBe(true);
603+
},
604+
);
605+
556606
it("repairs stale managed openclaw root packages before npm plugin installs", async () => {
557607
const stateDir = suiteTempRootTracker.makeTempDir();
558608
const npmRoot = path.join(stateDir, "npm");

src/plugins/install.ts

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,14 @@ type ValidatedPackagePlugin = {
744744
peerDependencies: Record<string, string>;
745745
};
746746

747+
function resolveOpenClawHostLinkDependencies(manifest: PackageManifest): Record<string, string> {
748+
const spec =
749+
manifest.peerDependencies?.openclaw ??
750+
manifest.dependencies?.openclaw ??
751+
manifest.optionalDependencies?.openclaw;
752+
return spec ? { openclaw: spec } : {};
753+
}
754+
747755
async function validatePackagePluginInstallSource(params: {
748756
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
749757
packageDir: string;
@@ -901,7 +909,7 @@ async function validatePackagePluginInstallSource(params: {
901909
version: typeof manifest.version === "string" ? manifest.version : undefined,
902910
extensions,
903911
hasRuntimeDependencies: hasPackageRuntimeDependencies(manifest),
904-
peerDependencies: manifest.peerDependencies ?? {},
912+
peerDependencies: resolveOpenClawHostLinkDependencies(manifest),
905913
},
906914
};
907915
}
@@ -913,7 +921,15 @@ async function scanAndLinkInstalledPackage(params: {
913921
pluginId: string;
914922
peerDependencies: Record<string, string>;
915923
logger: PluginInstallLogger;
924+
linkOpenClawBeforeScan?: boolean;
916925
}): Promise<Extract<InstallPluginResult, { ok: false }> | null> {
926+
if (params.linkOpenClawBeforeScan) {
927+
await linkOpenClawPeerDependencies({
928+
installedDir: params.installedDir,
929+
peerDependencies: params.peerDependencies,
930+
logger: params.logger,
931+
});
932+
}
917933
const scanResult = await runInstallSourceScan({
918934
subject: `Plugin "${params.pluginId}"`,
919935
scan: async () =>
@@ -929,11 +945,13 @@ async function scanAndLinkInstalledPackage(params: {
929945
if (scanResult) {
930946
return scanResult;
931947
}
932-
await linkOpenClawPeerDependencies({
933-
installedDir: params.installedDir,
934-
peerDependencies: params.peerDependencies,
935-
logger: params.logger,
936-
});
948+
if (!params.linkOpenClawBeforeScan) {
949+
await linkOpenClawPeerDependencies({
950+
installedDir: params.installedDir,
951+
peerDependencies: params.peerDependencies,
952+
logger: params.logger,
953+
});
954+
}
937955
return null;
938956
}
939957

@@ -966,6 +984,7 @@ export async function installPluginFromInstalledPackageDir(
966984
pluginId: validated.plugin.pluginId,
967985
peerDependencies: validated.plugin.peerDependencies,
968986
logger,
987+
linkOpenClawBeforeScan: params.dependencyScanRootDir !== undefined,
969988
});
970989
if (postInstallError) {
971990
return postInstallError;
@@ -1216,6 +1235,40 @@ export async function installPluginFromFile(params: {
12161235
return buildFileInstallResult(pluginId, preparedTarget.targetPath);
12171236
}
12181237

1238+
async function repairManagedNpmRootOpenClawPeerForInstall(params: {
1239+
logger: PluginInstallLogger;
1240+
npmRoot: string;
1241+
phase: "before npm install" | "after npm install";
1242+
timeoutMs: number;
1243+
trustedManagedNpmRoot?: boolean;
1244+
}): Promise<void> {
1245+
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
1246+
defaultNpmRoot: resolveDefaultPluginNpmDir(),
1247+
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
1248+
hostPackageRoot: resolveOpenClawPackageRootSync({
1249+
argv1: process.argv[1],
1250+
moduleUrl: import.meta.url,
1251+
cwd: process.cwd(),
1252+
}),
1253+
npmRoot: params.npmRoot,
1254+
runCommand: runCommandWithTimeout,
1255+
timeoutMs: params.timeoutMs,
1256+
trustedByInstallRecord: params.trustedManagedNpmRoot,
1257+
});
1258+
for (const warning of repairedOpenClawPeer.warnings) {
1259+
params.logger.warn?.(warning);
1260+
}
1261+
if (repairedOpenClawPeer.status === "repaired") {
1262+
params.logger.info?.(
1263+
`Repaired stale openclaw peer dependency in ${params.npmRoot} ${params.phase}`,
1264+
);
1265+
} else if (repairedOpenClawPeer.status === "skipped") {
1266+
params.logger.warn?.(
1267+
`Skipped stale openclaw peer repair in ${params.npmRoot} ${params.phase}: ${repairedOpenClawPeer.reason ?? "unproven managed npm root"}`,
1268+
);
1269+
}
1270+
}
1271+
12191272
export async function installPluginFromNpmSpec(
12201273
params: InstallSafetyOverrides & {
12211274
spec: string;
@@ -1340,29 +1393,13 @@ export async function installPluginFromNpmSpec(
13401393

13411394
logger.info?.(`Installing ${spec} into ${npmRoot}…`);
13421395
if (parsedSpec.name !== "openclaw") {
1343-
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
1344-
defaultNpmRoot: resolveDefaultPluginNpmDir(),
1345-
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
1346-
hostPackageRoot: resolveOpenClawPackageRootSync({
1347-
argv1: process.argv[1],
1348-
moduleUrl: import.meta.url,
1349-
cwd: process.cwd(),
1350-
}),
1396+
await repairManagedNpmRootOpenClawPeerForInstall({
1397+
logger,
13511398
npmRoot,
1352-
runCommand: runCommandWithTimeout,
1399+
phase: "before npm install",
13531400
timeoutMs,
1354-
trustedByInstallRecord: params.trustedManagedNpmRoot,
1401+
trustedManagedNpmRoot: params.trustedManagedNpmRoot,
13551402
});
1356-
for (const warning of repairedOpenClawPeer.warnings) {
1357-
logger.warn?.(warning);
1358-
}
1359-
if (repairedOpenClawPeer.status === "repaired") {
1360-
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`);
1361-
} else if (repairedOpenClawPeer.status === "skipped") {
1362-
logger.warn?.(
1363-
`Skipped stale openclaw peer repair in ${npmRoot}: ${repairedOpenClawPeer.reason ?? "unproven managed npm root"}`,
1364-
);
1365-
}
13661403
}
13671404
await upsertManagedNpmRootDependency({
13681405
npmRoot,
@@ -1377,6 +1414,7 @@ export async function installPluginFromNpmSpec(
13771414
"npm",
13781415
...createSafeNpmInstallArgs({
13791416
omitDev: true,
1417+
omitPeer: true,
13801418
loglevel: "error",
13811419
noAudit: true,
13821420
noFund: true,
@@ -1401,6 +1439,16 @@ export async function installPluginFromNpmSpec(
14011439
};
14021440
}
14031441

1442+
if (parsedSpec.name !== "openclaw") {
1443+
await repairManagedNpmRootOpenClawPeerForInstall({
1444+
logger,
1445+
npmRoot,
1446+
phase: "after npm install",
1447+
timeoutMs,
1448+
trustedManagedNpmRoot: params.trustedManagedNpmRoot,
1449+
});
1450+
}
1451+
14041452
let installedDependency: ManagedNpmRootInstalledDependency | null;
14051453
try {
14061454
installedDependency = await readManagedNpmRootInstalledDependency({

0 commit comments

Comments
 (0)