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

@@ -59,6 +59,8 @@ Docs: https://docs.openclaw.ai
- Plugins/update: treat official externalized bundled npm migrations and ClawHub-to-npm fallbacks as trusted source-linked installs, so prerelease-only official plugin packages can migrate from bundled builds without being rejected as unsafe prerelease resolutions. Thanks @vincentkoc.
- Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc.
- Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc.
- Plugins/update: repair stale managed npm-root `openclaw` peer packages before plugin installs, so beta-channel official plugin updates are not downgraded by old core package-lock state. Thanks @vincentkoc.
- Plugins/install: reassert managed npm plugin `openclaw` peer links after shared-root npm installs, updates, and uninstalls, so mutating one plugin does not leave previously installed SDK-using plugins unable to resolve `openclaw/plugin-sdk/*`.
- Plugins/update: make package upgrades swap pnpm/npm-prefix installs cleanly, keep legacy plugin install runtime chunks working, and on the beta channel fall back default-line npm plugins to default/latest when plugin beta releases are missing or fail install validation. Thanks @vincentkoc and @joshavant.
- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys.
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.

View File

@@ -51,6 +51,14 @@ the plugin package. OpenClaw scans the managed npm root before trusting the
install and uses npm to remove npm-managed packages during uninstall, so hoisted
runtime dependencies stay inside the managed cleanup boundary.
Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer
dependency. OpenClaw does not let npm install a separate registry copy of the
host package into the managed root, because stale host packages can affect npm
peer resolution during later plugin installs. Instead, after npm finishes
mutating the shared root during install, update, or uninstall, OpenClaw reasserts
plugin-local `node_modules/openclaw` links for installed packages that declare
the host peer.
git installs clone or refresh the repository, then run:
```bash

View File

@@ -9,7 +9,9 @@ const NPM_CONFIG_KEYS_TO_RESET = new Set([
"npm_config_include_workspace_root",
"npm_config_ignore_scripts",
"npm_config_location",
"npm_config_legacy_peer_deps",
"npm_config_prefix",
"npm_config_strict_peer_deps",
"npm_config_workspace",
"npm_config_workspaces",
]);

View File

@@ -1,8 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
repairManagedNpmRootOpenClawPeer,
removeManagedNpmRootDependency,
readManagedNpmRootInstalledDependency,
resolveManagedNpmRootDependencySpec,
@@ -11,6 +12,15 @@ import {
const tempDirs: string[] = [];
const successfulSpawn = {
code: 0,
stdout: "",
stderr: "",
signal: null,
killed: false,
termination: "exit" as const,
};
async function makeTempRoot(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-managed-root-"));
tempDirs.push(dir);
@@ -183,4 +193,128 @@ describe("managed npm root", () => {
},
});
});
it("repairs stale managed openclaw peer state without dropping plugin packages", async () => {
const npmRoot = await makeTempRoot();
await fs.mkdir(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
await fs.writeFile(
path.join(npmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
openclaw: "2026.5.4",
"@openclaw/discord": "2026.5.4",
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(npmRoot, "package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"": {
dependencies: {
openclaw: "2026.5.4",
"@openclaw/discord": "2026.5.4",
},
},
"node_modules/openclaw": {
version: "2026.5.4",
},
"node_modules/@openclaw/discord": {
version: "2026.5.4",
},
},
dependencies: {
openclaw: {
version: "2026.5.4",
},
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
`${JSON.stringify({ name: "openclaw", version: "2026.5.4" })}\n`,
);
await fs.mkdir(path.join(npmRoot, "node_modules", ".bin"), { recursive: true });
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw"), "shim");
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.cmd"), "cmd shim");
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.ps1"), "ps1 shim");
await fs.writeFile(
path.join(npmRoot, "node_modules", ".package-lock.json"),
`${JSON.stringify(
{
lockfileVersion: 3,
packages: {
"node_modules/openclaw": {
version: "2026.5.4",
},
},
},
null,
2,
)}\n`,
);
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot, runCommand })).resolves.toBe(true);
expect(runCommand).toHaveBeenCalledWith(
[
"npm",
"uninstall",
"--loglevel=error",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--prefix",
".",
"openclaw",
],
expect.objectContaining({
cwd: npmRoot,
}),
);
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
expect(manifest.dependencies).toEqual({
"@openclaw/discord": "2026.5.4",
});
const lockfile = JSON.parse(
await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8"),
) as {
packages?: Record<string, { dependencies?: Record<string, string>; version?: string }>;
dependencies?: Record<string, unknown>;
};
expect(lockfile.packages?.[""]?.dependencies).toEqual({
"@openclaw/discord": "2026.5.4",
});
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
expect(lockfile.packages?.["node_modules/@openclaw/discord"]?.version).toBe("2026.5.4");
expect(lockfile.dependencies?.openclaw).toBeUndefined();
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
code: "ENOENT",
});
for (const binName of ["openclaw", "openclaw.cmd", "openclaw.ps1"]) {
await expect(
fs.lstat(path.join(npmRoot, "node_modules", ".bin", binName)),
).rejects.toMatchObject({
code: "ENOENT",
});
}
await expect(
fs.lstat(path.join(npmRoot, "node_modules", ".package-lock.json")),
).rejects.toMatchObject({
code: "ENOENT",
});
});
});

View File

@@ -1,8 +1,10 @@
import fs from "node:fs/promises";
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 { createSafeNpmInstallEnv } from "./safe-package-install.js";
type ManagedNpmRootManifest = {
private?: boolean;
@@ -16,6 +18,18 @@ export type ManagedNpmRootInstalledDependency = {
resolved?: string;
};
type ManagedNpmRootLockfile = {
packages?: Record<string, unknown>;
dependencies?: Record<string, unknown>;
[key: string]: unknown;
};
type ManagedNpmRootLogger = {
warn?: (message: string) => void;
};
type ManagedNpmRootRunCommand = typeof runCommandWithTimeout;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -69,6 +83,168 @@ export async function upsertManagedNpmRootDependency(params: {
await writeJson(manifestPath, next, { trailingNewline: true });
}
export async function repairManagedNpmRootOpenClawPeer(params: {
npmRoot: string;
timeoutMs?: number;
logger?: ManagedNpmRootLogger;
runCommand?: ManagedNpmRootRunCommand;
}): Promise<boolean> {
await fs.mkdir(params.npmRoot, { recursive: true });
const manifestPath = path.join(params.npmRoot, "package.json");
const manifest = await readManagedNpmRootManifest(manifestPath);
const dependencies = readDependencyRecord(manifest.dependencies);
const hasManifestDependency = "openclaw" in dependencies;
const hasLockDependency = await managedNpmRootLockfileHasOpenClawPeer(params.npmRoot);
const hasPackageDir = await pathExists(path.join(params.npmRoot, "node_modules", "openclaw"));
if (!hasManifestDependency && !hasLockDependency && !hasPackageDir) {
return false;
}
const command = params.runCommand ?? runCommandWithTimeout;
const npmArgs = hasManifestDependency
? [
"npm",
"uninstall",
"--loglevel=error",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--prefix",
".",
"openclaw",
]
: [
"npm",
"prune",
"--loglevel=error",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--prefix",
".",
];
try {
const result = await command(npmArgs, {
cwd: params.npmRoot,
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
});
if (result.code !== 0) {
params.logger?.warn?.(
`npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${result.stderr.trim() || result.stdout.trim()}`,
);
}
} catch (error) {
params.logger?.warn?.(
`npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${String(error)}`,
);
}
await scrubManagedNpmRootOpenClawPeer({ npmRoot: params.npmRoot });
return true;
}
async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<boolean> {
const lockPath = path.join(npmRoot, "package-lock.json");
try {
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
if (isRecord(parsed.packages)) {
const rootPackage = parsed.packages[""];
if (
isRecord(rootPackage) &&
isRecord(rootPackage.dependencies) &&
"openclaw" in rootPackage.dependencies
) {
return true;
}
if ("node_modules/openclaw" in parsed.packages) {
return true;
}
}
return isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw err;
}
}
async function pathExists(filePath: string): Promise<boolean> {
return await fs
.lstat(filePath)
.then(() => true)
.catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
return false;
}
throw err;
});
}
async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Promise<void> {
const manifestPath = path.join(params.npmRoot, "package.json");
const manifest = await readManagedNpmRootManifest(manifestPath);
const dependencies = readDependencyRecord(manifest.dependencies);
if ("openclaw" in dependencies) {
const { openclaw: _removed, ...nextDependencies } = dependencies;
await fs.writeFile(
manifestPath,
`${JSON.stringify({ ...manifest, private: true, dependencies: nextDependencies }, null, 2)}\n`,
"utf8",
);
}
const lockPath = path.join(params.npmRoot, "package-lock.json");
try {
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
let lockChanged = false;
if (isRecord(parsed.packages)) {
const rootPackage = parsed.packages[""];
if (isRecord(rootPackage) && isRecord(rootPackage.dependencies)) {
const dependencies = { ...rootPackage.dependencies };
if ("openclaw" in dependencies) {
delete dependencies.openclaw;
parsed.packages[""] = { ...rootPackage, dependencies };
lockChanged = true;
}
}
if ("node_modules/openclaw" in parsed.packages) {
delete parsed.packages["node_modules/openclaw"];
lockChanged = true;
}
}
if (isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies) {
const dependencies = { ...parsed.dependencies };
delete dependencies.openclaw;
parsed.dependencies = dependencies;
lockChanged = true;
}
if (lockChanged) {
await fs.writeFile(lockPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
if (await pathExists(openclawPackageDir)) {
await fs.rm(openclawPackageDir, { recursive: true, force: true });
}
const binDir = path.join(params.npmRoot, "node_modules", ".bin");
await Promise.all(
["openclaw", "openclaw.cmd", "openclaw.ps1"].map((binName) =>
fs.rm(path.join(binDir, binName), { force: true }),
),
);
await fs.rm(path.join(params.npmRoot, "node_modules", ".package-lock.json"), {
force: true,
});
}
export async function readManagedNpmRootInstalledDependency(params: {
npmRoot: string;
packageName: string;

View File

@@ -28,6 +28,8 @@ describe("safe npm install helpers", () => {
{
PATH: "/usr/bin:/bin",
NPM_CONFIG_IGNORE_SCRIPTS: "false",
NPM_CONFIG_LEGACY_PEER_DEPS: "false",
NPM_CONFIG_STRICT_PEER_DEPS: "true",
npm_config_global: "true",
npm_config_include_workspace_root: "true",
npm_config_ignore_scripts: "false",
@@ -64,11 +66,26 @@ describe("safe npm install helpers", () => {
npm_config_package_lock: "false",
npm_config_progress: "false",
npm_config_save: "false",
npm_config_strict_peer_deps: "false",
npm_config_workspaces: "false",
npm_config_yes: "true",
});
});
it("does not inherit host legacy peer dependency mode by default", () => {
expect(
createSafeNpmInstallEnv({
PATH: "/usr/bin:/bin",
npm_config_legacy_peer_deps: "true",
npm_config_strict_peer_deps: "true",
}),
).toMatchObject({
PATH: "/usr/bin:/bin",
npm_config_legacy_peer_deps: "false",
npm_config_strict_peer_deps: "false",
});
});
it("allows package-lock-enabled installs to write lockfiles", () => {
expect(
createSafeNpmInstallEnv(

View File

@@ -27,10 +27,11 @@ export function createSafeNpmInstallEnv(
npm_config_audit: "false",
npm_config_fund: "false",
npm_config_ignore_scripts: "true",
npm_config_legacy_peer_deps: options.legacyPeerDeps ? "true" : "false",
npm_config_package_lock: options.packageLock === true ? "true" : "false",
npm_config_strict_peer_deps: "false",
...(options.packageLock === true ? { npm_config_save: "true" } : {}),
...(options.ignoreWorkspaces ? { npm_config_workspaces: "false" } : {}),
...(options.legacyPeerDeps ? { npm_config_legacy_peer_deps: "true" } : {}),
};
if (options.quiet) {
Object.assign(nextEnv, {

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 });