diff --git a/CHANGELOG.md b/CHANGELOG.md index ffcac7995ad..5ffcb9648c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index 8524288a3d7..9aa509d555b 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -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", + }), }), ); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index d06fcde46b4..3d270b4d834 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -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?.( diff --git a/src/infra/safe-package-install.test.ts b/src/infra/safe-package-install.test.ts index 9c64e398435..beccab94064 100644 --- a/src/infra/safe-package-install.test.ts +++ b/src/infra/safe-package-install.test.ts @@ -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", diff --git a/src/infra/safe-package-install.ts b/src/infra/safe-package-install.ts index 001f3353019..e9be759a70d 100644 --- a/src/infra/safe-package-install.ts +++ b/src/infra/safe-package-install.ts @@ -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"] : []), diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 8f5e35238e1..a4ec793304d 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -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 () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 46396ad64fd..21ad032b63d 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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) { diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 47acb3480eb..8f59e5a0a08 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -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); }); diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 1f1f820ee47..fe8eefb79bd 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -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) {