fix(plugins): keep managed npm mutations in legacy peer mode

This commit is contained in:
Vincent Koc
2026-05-06 01:23:33 -07:00
parent b902d86318
commit 0ddbf2e258
9 changed files with 48 additions and 5 deletions

View File

@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
- 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: run managed npm-root install, rollback, repair, and uninstall mutations with legacy peer resolution so removing one plugin cannot rehydrate a stale registry `openclaw` package into the shared root. 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.

View File

@@ -271,6 +271,7 @@ describe("managed npm root", () => {
"npm",
"uninstall",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
@@ -280,6 +281,9 @@ describe("managed npm root", () => {
],
expect.objectContaining({
cwd: npmRoot,
env: expect.objectContaining({
npm_config_legacy_peer_deps: "true",
}),
}),
);

View File

@@ -107,6 +107,7 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
"npm",
"uninstall",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
@@ -118,6 +119,7 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
"npm",
"prune",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
@@ -128,7 +130,11 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
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 }),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
packageLock: true,
quiet: true,
}),
});
if (result.code !== 0) {
params.logger?.warn?.(

View File

@@ -7,6 +7,7 @@ describe("safe npm install helpers", () => {
createSafeNpmInstallArgs({
omitDev: true,
ignoreWorkspaces: true,
legacyPeerDeps: true,
loglevel: "error",
noAudit: true,
noFund: true,
@@ -15,6 +16,7 @@ describe("safe npm install helpers", () => {
"install",
"--omit=dev",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--workspaces=false",
"--no-audit",

View File

@@ -10,6 +10,7 @@ type SafeNpmInstallEnvOptions = NpmProjectInstallEnvOptions & {
type SafeNpmInstallArgsOptions = {
ignoreWorkspaces?: boolean;
legacyPeerDeps?: boolean;
loglevel?: "error" | "silent";
noAudit?: boolean;
noFund?: boolean;
@@ -48,6 +49,7 @@ export function createSafeNpmInstallArgs(options: SafeNpmInstallArgsOptions = {}
"install",
...(options.omitDev ? ["--omit=dev"] : []),
...(options.loglevel ? [`--loglevel=${options.loglevel}`] : []),
...(options.legacyPeerDeps ? ["--legacy-peer-deps"] : []),
"--ignore-scripts",
...(options.ignoreWorkspaces ? ["--workspaces=false"] : []),
...(options.noAudit ? ["--no-audit"] : []),

View File

@@ -64,6 +64,7 @@ function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string
"install",
"--omit=dev",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
@@ -922,6 +923,9 @@ describe("installPluginFromNpmSpec", () => {
};
}
if (argv[0] === "npm" && argv[1] === "uninstall") {
if (!argv.includes("--legacy-peer-deps")) {
fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
}
return successfulSpawn("");
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
@@ -945,6 +949,9 @@ describe("installPluginFromNpmSpec", () => {
dependencies: {},
});
expect(fs.lstatSync(peerLink).isSymbolicLink()).toBe(true);
await expect(
fs.promises.access(path.join(npmRoot, "node_modules", "openclaw")),
).rejects.toThrow();
});
it("rolls back installed npm package debris when security scan blocks the plugin", async () => {

View File

@@ -323,6 +323,7 @@ async function rollbackManagedNpmPluginInstall(params: {
"npm",
"uninstall",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
@@ -333,7 +334,11 @@ async function rollbackManagedNpmPluginInstall(params: {
{
cwd: params.npmRoot,
timeoutMs: Math.max(params.timeoutMs, 300_000),
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
packageLock: true,
quiet: true,
}),
},
);
} catch (error) {
@@ -487,6 +492,7 @@ async function installPluginFromManagedNpmRoot(
...createSafeNpmInstallArgs({
omitDev: true,
loglevel: "error",
legacyPeerDeps: true,
noAudit: true,
noFund: true,
}),
@@ -496,7 +502,11 @@ async function installPluginFromManagedNpmRoot(
{
cwd: npmRoot,
timeoutMs: Math.max(timeoutMs, 300_000),
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
packageLock: true,
quiet: true,
}),
},
);
if (install.code !== 0) {

View File

@@ -974,6 +974,7 @@ describe("uninstallPlugin", () => {
"npm",
"uninstall",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
@@ -986,6 +987,7 @@ describe("uninstallPlugin", () => {
timeoutMs: 300_000,
env: expect.objectContaining({
NPM_CONFIG_IGNORE_SCRIPTS: "true",
npm_config_legacy_peer_deps: "true",
npm_config_package_lock: "true",
}),
}),
@@ -1015,8 +1017,11 @@ describe("uninstallPlugin", () => {
)}\n`,
);
await fs.symlink(tempDir, peerLink, "junction");
runCommandWithTimeoutMock.mockImplementationOnce(async () => {
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
await fs.rm(peerLink, { recursive: true, force: true });
if (!argv.includes("--legacy-peer-deps")) {
await fs.mkdir(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true });
}
return {
code: 0,
stdout: "",
@@ -1038,6 +1043,7 @@ describe("uninstallPlugin", () => {
expect(applied).toEqual({ directoryRemoved: true, warnings: [] });
await expect(fs.access(removedPluginDir)).rejects.toThrow();
await expect(fs.access(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toThrow();
await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true);
});

View File

@@ -595,6 +595,7 @@ export async function applyPluginUninstallDirectoryRemoval(
"npm",
"uninstall",
"--loglevel=error",
"--legacy-peer-deps",
"--ignore-scripts",
"--no-audit",
"--no-fund",
@@ -605,7 +606,11 @@ export async function applyPluginUninstallDirectoryRemoval(
{
cwd: removal.cleanup.npmRoot,
timeoutMs: 300_000,
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
env: createSafeNpmInstallEnv(process.env, {
legacyPeerDeps: true,
packageLock: true,
quiet: true,
}),
},
);
if (uninstall.code !== 0) {