fix(plugins): repair managed npm openclaw peers

Remove stale managed-root openclaw manifests, locks, hidden locks, and installed copies before npm plugin installs.

Relink plugin-local openclaw peer symlinks after shared-root npm install, rollback, update, and uninstall mutations so SDK-using plugins keep resolving openclaw/plugin-sdk/*.

Force safe npm commands out of inherited legacy/strict peer-dependency modes.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Peter Steinberger
2026-05-06 07:32:25 +01:00
committed by GitHub
parent 8cc762daff
commit 8e533490ab
13 changed files with 993 additions and 6 deletions

View File

@@ -1,15 +1,18 @@
import { execFileSync } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { installPluginFromNpmSpec } from "./install.js";
type PackedVersion = {
archive: Buffer;
integrity: string;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
shasum: string;
tarballName: string;
version: string;
@@ -19,6 +22,7 @@ const tempDirs: string[] = [];
const servers: http.Server[] = [];
const envKeys = ["NPM_CONFIG_REGISTRY", "npm_config_registry"] as const;
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
const execFileAsync = promisify(execFile);
afterEach(async () => {
for (const server of servers.splice(0)) {
@@ -43,11 +47,19 @@ async function makeTempDir(label: string): Promise<string> {
async function packPlugin(params: {
packageName: string;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
pluginId: string;
version: string;
rootDir: string;
}): Promise<PackedVersion> {
const packageDir = path.join(params.rootDir, `package-${params.version}`);
const packageDir = path.join(params.rootDir, `package-${params.packageName}-${params.version}`);
const peerDependenciesMeta = params.peerDependencies
? (params.peerDependenciesMeta ??
Object.fromEntries(
Object.keys(params.peerDependencies).map((name) => [name, { optional: true }]),
))
: undefined;
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
await fs.writeFile(
path.join(packageDir, "package.json"),
@@ -57,6 +69,12 @@ async function packPlugin(params: {
version: params.version,
type: "module",
openclaw: { extensions: ["./dist/index.js"] },
...(params.peerDependencies
? {
peerDependencies: params.peerDependencies,
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
}
: {}),
},
null,
2,
@@ -92,12 +110,90 @@ async function packPlugin(params: {
return {
archive,
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
tarballName,
version: params.version,
};
}
async function startStaticRegistry(
packages: Array<{
latest: string;
packageName: string;
versions: PackedVersion[];
}>,
): Promise<string> {
const packageEntries = packages.map((pkg) => ({
...pkg,
encodedPackageName: encodeURIComponent(pkg.packageName).replace("%40", "@"),
versionsByVersion: new Map(pkg.versions.map((entry) => [entry.version, entry])),
}));
const server = http.createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
if (request.method !== "GET") {
response.writeHead(405, { "content-type": "text/plain" });
response.end("method not allowed");
return;
}
for (const pkg of packageEntries) {
if (url.pathname === `/${pkg.encodedPackageName}`) {
response.writeHead(200, { "content-type": "application/json" });
response.end(
`${JSON.stringify({
name: pkg.packageName,
"dist-tags": { latest: pkg.latest },
versions: Object.fromEntries(
[...pkg.versionsByVersion.entries()].map(([version, entry]) => [
version,
{
name: pkg.packageName,
version,
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
...(entry.peerDependenciesMeta
? { peerDependenciesMeta: entry.peerDependenciesMeta }
: {}),
dist: {
integrity: entry.integrity,
shasum: entry.shasum,
tarball: `${baseUrl}/${pkg.encodedPackageName}/-/${entry.tarballName}`,
},
},
]),
),
})}\n`,
);
return;
}
const tarballPrefix = `/${pkg.encodedPackageName}/-/`;
if (url.pathname.startsWith(tarballPrefix)) {
const entry = [...pkg.versionsByVersion.values()].find((candidate) =>
url.pathname.endsWith(`/${candidate.tarballName}`),
);
if (entry) {
response.writeHead(200, {
"content-length": String(entry.archive.length),
"content-type": "application/octet-stream",
});
response.end(entry.archive);
return;
}
}
}
response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
servers.push(server);
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
}
async function startMutableRegistry(params: {
packageName: string;
initialLatest: string;
@@ -135,6 +231,10 @@ async function startMutableRegistry(params: {
{
name: params.packageName,
version,
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
...(entry.peerDependenciesMeta
? { peerDependenciesMeta: entry.peerDependenciesMeta }
: {}),
dist: {
integrity: entry.integrity,
shasum: entry.shasum,
@@ -173,6 +273,155 @@ async function startMutableRegistry(params: {
}
describe("installPluginFromNpmSpec e2e", () => {
it("scrubs root openclaw materialized by required npm peers", async () => {
const rootDir = await makeTempDir("npm-plugin-required-peer-e2e");
const npmRoot = path.join(rootDir, "managed-npm");
const packageName = `required-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const versions = [
await packPlugin({
packageName,
peerDependencies: { openclaw: ">=2026.0.0" },
peerDependenciesMeta: {},
pluginId: packageName,
version: "1.0.0",
rootDir,
}),
];
const openClawVersions = [
await packPlugin({
packageName: "openclaw",
pluginId: "registry-openclaw-copy",
version: "2026.0.0",
rootDir,
}),
];
const registry = await startStaticRegistry([
{ packageName, latest: "1.0.0", versions },
{ packageName: "openclaw", latest: "2026.0.0", versions: openClawVersions },
]);
process.env.NPM_CONFIG_REGISTRY = registry;
process.env.npm_config_registry = registry;
const rawNpmRoot = path.join(rootDir, "raw-managed-npm");
await fs.mkdir(rawNpmRoot, { recursive: true });
await fs.writeFile(
path.join(rawNpmRoot, "package.json"),
`${JSON.stringify({ private: true, dependencies: { [packageName]: "1.0.0" } }, null, 2)}\n`,
"utf8",
);
await execFileAsync(
"npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"],
{
cwd: rawNpmRoot,
env: {
...process.env,
NPM_CONFIG_REGISTRY: registry,
NPM_CONFIG_LEGACY_PEER_DEPS: "false",
NPM_CONFIG_STRICT_PEER_DEPS: "false",
npm_config_registry: registry,
npm_config_legacy_peer_deps: "false",
npm_config_strict_peer_deps: "false",
},
timeout: 120_000,
},
);
const rawLock = JSON.parse(
await fs.readFile(path.join(rawNpmRoot, "package-lock.json"), "utf8"),
) as {
packages?: Record<string, unknown>;
};
expect(rawLock.packages?.["node_modules/openclaw"]).toMatchObject({
peer: true,
version: "2026.0.0",
});
const result = await installPluginFromNpmSpec({
spec: `${packageName}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
timeoutMs: 120_000,
});
if (!result.ok) {
throw new Error(result.error);
}
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
packages?: Record<string, unknown>;
};
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(
fs
.lstat(path.join(result.targetDir, "node_modules", "openclaw"))
.then((stat) => stat.isSymbolicLink()),
).resolves.toBe(true);
});
it("relinks managed npm sibling openclaw peers after later plugin installs", async () => {
const rootDir = await makeTempDir("npm-plugin-peer-e2e");
const npmRoot = path.join(rootDir, "managed-npm");
const peerPackageName = `peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const laterPackageName = `later-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const peerVersions = [
await packPlugin({
packageName: peerPackageName,
peerDependencies: { openclaw: ">=2026.0.0" },
pluginId: peerPackageName,
version: "1.0.0",
rootDir,
}),
];
const laterVersions = [
await packPlugin({
packageName: laterPackageName,
pluginId: laterPackageName,
version: "1.0.0",
rootDir,
}),
];
const registry = await startStaticRegistry([
{ packageName: peerPackageName, latest: "1.0.0", versions: peerVersions },
{ packageName: laterPackageName, latest: "1.0.0", versions: laterVersions },
]);
process.env.NPM_CONFIG_REGISTRY = registry;
process.env.npm_config_registry = registry;
const first = await installPluginFromNpmSpec({
spec: `${peerPackageName}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
timeoutMs: 120_000,
});
if (!first.ok) {
throw new Error(first.error);
}
const peerLink = path.join(first.targetDir, "node_modules", "openclaw");
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
const second = await installPluginFromNpmSpec({
spec: `${laterPackageName}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
timeoutMs: 120_000,
});
if (!second.ok) {
throw new Error(second.error);
}
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
expect(manifest.dependencies?.openclaw).toBeUndefined();
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
packages?: Record<string, unknown>;
};
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
});
it("pins a mutable npm tag to the version resolved before install", async () => {
const rootDir = await makeTempDir("npm-plugin-e2e");
const npmRoot = path.join(rootDir, "managed-npm");

View File

@@ -141,6 +141,7 @@ type MockNpmPackage = {
versions?: string[];
installedVersion?: string;
installedIntegrity?: string;
materializesRootOpenClaw?: boolean;
skipLockfileEntry?: boolean;
};
@@ -162,6 +163,12 @@ function writeNpmRootPackageLock(params: {
version: pkg.installedVersion ?? pkg.version,
integrity: pkg.installedIntegrity ?? pkg.integrity ?? "sha512-plugin-test",
};
if (pkg.materializesRootOpenClaw) {
lockPackages["node_modules/openclaw"] = {
peer: true,
version: "2026.5.3",
};
}
}
fs.writeFileSync(
path.join(params.npmRoot, "package-lock.json"),
@@ -170,6 +177,31 @@ function writeNpmRootPackageLock(params: {
);
}
function prunePluginLocalOpenClawPeerLinks(npmRoot: string) {
const nodeModulesDir = path.join(npmRoot, "node_modules");
if (!fs.existsSync(nodeModulesDir)) {
return;
}
for (const entry of fs.readdirSync(nodeModulesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const entryPath = path.join(nodeModulesDir, entry.name);
const packageDirs = entry.name.startsWith("@")
? fs
.readdirSync(entryPath, { withFileTypes: true })
.filter((scopedEntry) => scopedEntry.isDirectory())
.map((scopedEntry) => path.join(entryPath, scopedEntry.name))
: [entryPath];
for (const packageDir of packageDirs) {
fs.rmSync(path.join(packageDir, "node_modules", "openclaw"), {
recursive: true,
force: true,
});
}
}
}
function mockNpmViewAndInstall(params: {
spec: string;
packageName: string;
@@ -186,6 +218,7 @@ function mockNpmViewAndInstall(params: {
versions?: string[];
installedVersion?: string;
installedIntegrity?: string;
materializesRootOpenClaw?: boolean;
skipLockfileEntry?: boolean;
}) {
mockNpmViewAndInstallMany([params]);
@@ -231,6 +264,7 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
dependencies?: Record<string, string>;
};
const installedPackages: MockNpmPackage[] = [];
prunePluginLocalOpenClawPeerLinks(npmRoot);
for (const packageName of Object.keys(manifest.dependencies ?? {})) {
const pkg = packagesByName.get(packageName);
if (!pkg) {
@@ -246,6 +280,15 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
...pkg,
version: pkg.installedVersion ?? pkg.version,
});
if (pkg.materializesRootOpenClaw) {
const openclawRoot = path.join(npmRoot, "node_modules", "openclaw");
fs.mkdirSync(openclawRoot, { recursive: true });
fs.writeFileSync(
path.join(openclawRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.5.3" }),
"utf8",
);
}
installedPackages.push(pkg);
}
writeNpmRootPackageLock({
@@ -257,6 +300,19 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
}
if (argv[0] === "npm" && argv[1] === "uninstall") {
const packageName = argv.at(-1);
if (packageName === "openclaw") {
const prefixIndex = argv.indexOf("--prefix");
const prefixValue = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined;
const npmRoot = prefixValue === "." ? options?.cwd : prefixValue;
if (!npmRoot) {
throw new Error(`unexpected npm uninstall command: ${argv.join(" ")}`);
}
fs.rmSync(path.join(npmRoot, "node_modules", "openclaw"), {
recursive: true,
force: true,
});
return successfulSpawn();
}
const pkg = packageName ? packagesByName.get(packageName) : undefined;
if (!pkg) {
throw new Error(`unexpected npm uninstall package: ${packageName ?? ""}`);
@@ -504,9 +560,142 @@ describe("installPluginFromNpmSpec", () => {
if (!second.ok) {
expect(second.error).not.toContain("peer-plugin/node_modules/openclaw");
}
expect(
fs
.lstatSync(path.join(npmRoot, "node_modules", "peer-plugin", "node_modules", "openclaw"))
.isSymbolicLink(),
).toBe(true);
},
);
it.runIf(process.platform !== "win32")(
"repairs root openclaw materialized by npm peer handling",
async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
mockNpmViewAndInstall({
spec: "required-peer-plugin@1.0.0",
packageName: "required-peer-plugin",
version: "1.0.0",
pluginId: "required-peer-plugin",
npmRoot,
peerDependencies: { openclaw: "^2026.0.0" },
materializesRootOpenClaw: true,
});
const result = await installPluginFromNpmSpec({
spec: "required-peer-plugin@1.0.0",
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(true);
expect(fs.existsSync(path.join(npmRoot, "node_modules", "openclaw"))).toBe(false);
const lockfile = JSON.parse(
fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"),
) as {
packages?: Record<string, unknown>;
};
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
expect(
fs
.lstatSync(
path.join(npmRoot, "node_modules", "required-peer-plugin", "node_modules", "openclaw"),
)
.isSymbolicLink(),
).toBe(true);
},
);
it("repairs stale managed openclaw root packages before npm plugin installs", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
fs.writeFileSync(
path.join(npmRoot, "package.json"),
JSON.stringify(
{
private: true,
dependencies: {
openclaw: "2026.5.4",
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(npmRoot, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: {
openclaw: "2026.5.4",
},
},
"node_modules/openclaw": {
version: "2026.5.4",
resolved: "https://registry.npmjs.org/openclaw/-/openclaw-2026.5.4.tgz",
},
},
dependencies: {
openclaw: {
version: "2026.5.4",
},
},
},
null,
2,
)}\n`,
"utf-8",
);
fs.writeFileSync(
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
JSON.stringify({
name: "openclaw",
version: "2026.5.4",
}),
"utf-8",
);
mockNpmViewAndInstall({
spec: "@openclaw/discord@beta",
packageName: "@openclaw/discord",
version: "2026.5.5-beta.1",
pluginId: "discord",
npmRoot,
peerDependencies: { openclaw: ">=2026.5.5-beta.1" },
expectedDependencySpec: "2026.5.5-beta.1",
});
const result = await installPluginFromNpmSpec({
spec: "@openclaw/discord@beta",
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).not.toHaveProperty("openclaw");
expect(manifest.dependencies).toMatchObject({
"@openclaw/discord": "2026.5.5-beta.1",
});
const lockfile = JSON.parse(
fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"),
) as {
packages?: Record<string, unknown>;
dependencies?: Record<string, unknown>;
};
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
expect(lockfile.dependencies?.openclaw).toBeUndefined();
});
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[] = [];
@@ -545,6 +734,19 @@ describe("installPluginFromNpmSpec", () => {
it("rolls back the managed npm root when npm install fails", async () => {
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const peerPluginDir = path.join(npmRoot, "node_modules", "peer-plugin");
const peerLink = path.join(peerPluginDir, "node_modules", "openclaw");
fs.mkdirSync(path.dirname(peerLink), { recursive: true });
fs.writeFileSync(
path.join(peerPluginDir, "package.json"),
JSON.stringify({
name: "peer-plugin",
version: "1.0.0",
peerDependencies: { openclaw: ">=2026.0.0" },
}),
"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(
@@ -559,6 +761,7 @@ describe("installPluginFromNpmSpec", () => {
);
}
if (argv[0] === "npm" && argv[1] === "install") {
fs.rmSync(peerLink, { recursive: true, force: true });
return {
code: 1,
stdout: "",
@@ -568,6 +771,9 @@ describe("installPluginFromNpmSpec", () => {
termination: "exit" as const,
};
}
if (argv[0] === "npm" && argv[1] === "uninstall") {
return successfulSpawn("");
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
});
@@ -588,6 +794,7 @@ describe("installPluginFromNpmSpec", () => {
).resolves.toMatchObject({
dependencies: {},
});
expect(fs.lstatSync(peerLink).isSymbolicLink()).toBe(true);
});
it("rolls back installed npm package debris when security scan blocks the plugin", async () => {

View File

@@ -9,6 +9,7 @@ import {
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
import {
readManagedNpmRootInstalledDependency,
repairManagedNpmRootOpenClawPeer,
removeManagedNpmRootDependency,
resolveManagedNpmRootDependencySpec,
upsertManagedNpmRootDependency,
@@ -47,7 +48,10 @@ import {
type PackageManifest as PluginPackageManifest,
} from "./manifest.js";
import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js";
import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js";
import {
linkOpenClawPeerDependencies,
relinkOpenClawPeerDependenciesInManagedNpmRoot,
} from "./plugin-peer-link.js";
export { resolvePluginInstallDir } from "./install-paths.js";
@@ -350,6 +354,16 @@ async function rollbackManagedNpmPluginInstall(params: {
`Failed to remove managed npm dependency ${params.packageName}: ${String(error)}`,
);
}
try {
await relinkOpenClawPeerDependenciesInManagedNpmRoot({
npmRoot: params.npmRoot,
logger: params.logger,
});
} catch (error) {
params.logger.warn?.(
`Failed to repair managed npm peer links after rollback for ${params.packageName}: ${String(error)}`,
);
}
}
function resolveInstalledNpmResolutionMismatch(params: {
@@ -1335,6 +1349,16 @@ export async function installPluginFromNpmSpec(
}
logger.info?.(`Installing ${spec} into ${npmRoot}`);
if (parsedSpec.name !== "openclaw") {
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
npmRoot,
timeoutMs,
logger,
});
if (repairedOpenClawPeer) {
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`);
}
}
await upsertManagedNpmRootDependency({
npmRoot,
packageName: parsedSpec.name,
@@ -1362,15 +1386,29 @@ export async function installPluginFromNpmSpec(
},
);
if (install.code !== 0) {
await removeManagedNpmRootDependency({
await rollbackManagedNpmPluginInstall({
npmRoot,
packageName: parsedSpec.name,
targetDir: installRoot,
timeoutMs,
logger,
});
return {
ok: false,
error: `npm install failed: ${install.stderr.trim() || install.stdout.trim()}`,
};
}
if (parsedSpec.name !== "openclaw") {
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
npmRoot,
timeoutMs,
logger,
});
if (repairedOpenClawPeer) {
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot} after npm install`);
}
}
await relinkOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot, logger });
let installedDependency: ManagedNpmRootInstalledDependency | null;
try {

View File

@@ -7,6 +7,76 @@ type PluginPeerLinkLogger = {
warn?: (message: string) => void;
};
type RelinkManagedNpmRootResult = {
checked: number;
attempted: number;
};
function readStringRecord(value: unknown): Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
const record: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw === "string") {
record[key] = raw;
}
}
return record;
}
async function readPackagePeerDependencies(packageDir: string): Promise<Record<string, string>> {
try {
const raw = await fs.readFile(path.join(packageDir, "package.json"), "utf8");
const parsed = JSON.parse(raw) as { peerDependencies?: unknown };
return readStringRecord(parsed.peerDependencies);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {};
}
throw error;
}
}
async function listManagedNpmRootPackageDirs(npmRoot: string): Promise<string[]> {
const nodeModulesDir = path.join(npmRoot, "node_modules");
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(nodeModulesDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
const packageDirs: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === ".bin") {
continue;
}
const entryPath = path.join(nodeModulesDir, entry.name);
if (entry.name.startsWith("@")) {
const scopedEntries = await fs.readdir(entryPath, { withFileTypes: true }).catch((error) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
});
for (const scopedEntry of scopedEntries) {
if (scopedEntry.isDirectory()) {
packageDirs.push(path.join(entryPath, scopedEntry.name));
}
}
continue;
}
if (!entry.name.startsWith(".")) {
packageDirs.push(entryPath);
}
}
return packageDirs.toSorted((a, b) => a.localeCompare(b));
}
/**
* Symlink the host openclaw package for plugins that declare it as a peer.
* Plugin package managers still own third-party dependencies; this only wires
@@ -49,3 +119,25 @@ export async function linkOpenClawPeerDependencies(params: {
}
}
}
export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
npmRoot: string;
logger: PluginPeerLinkLogger;
}): Promise<RelinkManagedNpmRootResult> {
let checked = 0;
let attempted = 0;
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
const peerDependencies = await readPackagePeerDependencies(packageDir);
if (!Object.hasOwn(peerDependencies, "openclaw")) {
continue;
}
checked += 1;
await linkOpenClawPeerDependencies({
installedDir: packageDir,
peerDependencies,
logger: params.logger,
});
attempted += 1;
}
return { checked, attempted };
}

View File

@@ -993,6 +993,54 @@ describe("uninstallPlugin", () => {
await expect(fs.access(pluginDir)).rejects.toThrow();
});
it("repairs remaining npm plugin openclaw peer links after npm uninstall prunes them", async () => {
const stateDir = path.join(tempDir, "state");
const npmRoot = path.join(stateDir, "npm");
const removedPluginDir = path.join(npmRoot, "node_modules", "removed-plugin");
const peerPluginDir = path.join(npmRoot, "node_modules", "peer-plugin");
const peerLink = path.join(peerPluginDir, "node_modules", "openclaw");
await fs.mkdir(removedPluginDir, { recursive: true });
await fs.mkdir(path.dirname(peerLink), { recursive: true });
await fs.writeFile(path.join(removedPluginDir, "package.json"), "{}\n");
await fs.writeFile(
path.join(peerPluginDir, "package.json"),
`${JSON.stringify(
{
name: "peer-plugin",
version: "1.0.0",
peerDependencies: { openclaw: ">=2026.0.0" },
},
null,
2,
)}\n`,
);
await fs.symlink(tempDir, peerLink, "junction");
runCommandWithTimeoutMock.mockImplementationOnce(async () => {
await fs.rm(peerLink, { recursive: true, force: true });
return {
code: 0,
stdout: "",
stderr: "",
signal: null,
killed: false,
termination: "exit",
};
});
const applied = await applyPluginUninstallDirectoryRemoval({
target: removedPluginDir,
cleanup: {
kind: "npm",
npmRoot,
packageName: "removed-plugin",
},
});
expect(applied).toEqual({ directoryRemoved: true, warnings: [] });
await expect(fs.access(removedPluginDir)).rejects.toThrow();
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
});
it("skips npm cleanup when the managed package directory is already absent", async () => {
const stateDir = path.join(tempDir, "state");
const npmRoot = path.join(stateDir, "npm");

View File

@@ -11,6 +11,7 @@ import {
resolveDefaultPluginNpmDir,
resolvePluginInstallDir,
} from "./install-paths.js";
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "./plugin-peer-link.js";
import { defaultSlotIdForKey } from "./slots.js";
export type UninstallActions = {
@@ -616,6 +617,18 @@ export async function applyPluginUninstallDirectoryRemoval(
}`,
);
}
try {
await relinkOpenClawPeerDependenciesInManagedNpmRoot({
npmRoot: removal.cleanup.npmRoot,
logger: {
warn: (message) => warnings.push(message),
},
});
} catch (error) {
warnings.push(
`Failed to repair managed npm peer links after uninstalling ${removal.cleanup.packageName}: ${formatErrorMessage(error)}`,
);
}
}
try {
await fs.rm(removal.target, { recursive: true, force: true });