mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 18:40:43 +00:00
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:
committed by
GitHub
parent
8cc762daff
commit
8e533490ab
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user