From f7522edb9607441421443159311b395b7d52cb7d Mon Sep 17 00:00:00 2001 From: byungskers Date: Sun, 3 May 2026 20:51:50 +0900 Subject: [PATCH] fix(plugins): preserve sibling npm installs Run npm install from the managed npm-root manifest so sequential @openclaw/* plugin installs preserve siblings on disk. Fixes #76571. Thanks @byungskers and @crpol. --- CHANGELOG.md | 1 + src/plugins/install.npm-spec.test.ts | 72 +++++++++++++++++++++++----- src/plugins/install.ts | 1 - 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae13e16276d..d7d5e6a819b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Active Memory: apply `setupGraceTimeoutMs` to the embedded recall runner as well as the outer prompt-build watchdog, so very-cold first recalls keep the configured setup grace end-to-end. (#74480) Thanks @volcano303. - Plugins/tools: keep disabled bundled tool plugins out of explicit runtime allowlist ownership and fall back from loaded-but-empty channel registries to tool-bearing plugin registries, so Active Memory can use bundled `memory-core` search/get tools even when `memory-lancedb` is disabled. Fixes #76603. Thanks @jwong-art. +- Plugins/install: run `npm install` from the managed npm-root manifest so installing one `@openclaw/*` plugin preserves already installed sibling plugins instead of pruning them. Fixes #76571. (#76602) Thanks @byungskers and @crpol. - Channels/QQ Bot: resolve structured `clientSecret` SecretRefs before QQ token exchange, expose the QQ Bot secret contract to secrets tooling, and reject legacy `secretref:/...` marker strings. (#74772) Thanks @xialonglee. - Plugins/externalization: keep official ACPX, Google Chat, and LINE install specs on production package names, leaving beta-tag probing to the explicit OpenClaw beta update channel. Thanks @vincentkoc. - CLI/doctor: keep missing-plugin repair from overriding official catalog metadata with runtime fallbacks, so ACPX repairs preserve the official npm spec during the externalization rollout. Thanks @vincentkoc. diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 7347d4424c8..9151c5ed80d 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -34,7 +34,7 @@ function npmViewArgv(spec: string): string[] { return ["npm", "view", spec, "name", "version", "dist.integrity", "dist.shasum", "--json"]; } -function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string; spec: string }) { +function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string }) { const installCalls = params.calls.filter( (call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install", ); @@ -49,7 +49,6 @@ function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string; "--no-fund", "--prefix", params.npmRoot, - params.spec, ]); } @@ -150,7 +149,6 @@ function mockNpmViewAndInstallMany( peerDependencies?: Record; }>, ) { - const packagesBySpec = new Map(packages.map((pkg) => [pkg.spec, pkg])); const packagesByName = new Map(packages.map((pkg) => [pkg.packageName, pkg])); runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => { const viewPackage = packages.find( @@ -169,12 +167,21 @@ function mockNpmViewAndInstallMany( ); } if (argv[0] === "npm" && argv[1] === "install") { - const spec = argv.at(-1); - const pkg = spec ? packagesBySpec.get(spec) : undefined; - if (!pkg) { - throw new Error(`unexpected npm install spec: ${spec ?? ""}`); + const prefixIndex = argv.indexOf("--prefix"); + const npmRoot = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined; + if (!npmRoot) { + throw new Error(`unexpected npm install command: ${argv.join(" ")}`); + } + const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as { + dependencies?: Record; + }; + for (const packageName of Object.keys(manifest.dependencies ?? {})) { + const pkg = packagesByName.get(packageName); + if (!pkg) { + throw new Error(`unexpected managed npm dependency: ${packageName}`); + } + writeInstalledNpmPlugin(pkg); } - writeInstalledNpmPlugin(pkg); return successfulSpawn(); } if (argv[0] === "npm" && argv[1] === "uninstall") { @@ -236,7 +243,6 @@ describe("installPluginFromNpmSpec", () => { expectNpmInstallIntoRoot({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, - spec: "@openclaw/voice-call@0.0.1", }); }); @@ -348,7 +354,6 @@ describe("installPluginFromNpmSpec", () => { expectNpmInstallIntoRoot({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, - spec: "dangerous-plugin@1.0.0", }); }); @@ -525,7 +530,6 @@ describe("installPluginFromNpmSpec", () => { expectNpmInstallIntoRoot({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, - spec, }); }, ); @@ -599,10 +603,53 @@ describe("installPluginFromNpmSpec", () => { expectNpmInstallIntoRoot({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, - spec: "@openclaw/voice-call@0.0.2", }); }); + it("preserves previously installed sibling plugins during npm install", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + + mockNpmViewAndInstallMany([ + { + spec: "@openclaw/voice-call@0.0.1", + packageName: "@openclaw/voice-call", + version: "0.0.1", + pluginId: "voice-call", + npmRoot, + }, + { + spec: "@openclaw/whatsapp@0.0.1", + packageName: "@openclaw/whatsapp", + version: "0.0.1", + pluginId: "whatsapp", + npmRoot, + }, + ]); + + const result1 = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call@0.0.1", + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + }); + expect(result1.ok).toBe(true); + + runCommandWithTimeoutMock.mockClear(); + const result2 = await installPluginFromNpmSpec({ + spec: "@openclaw/whatsapp@0.0.1", + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + }); + expect(result2.ok).toBe(true); + + expectNpmInstallIntoRoot({ + calls: runCommandWithTimeoutMock.mock.calls, + npmRoot, + }); + expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "voice-call"))).toBe(true); + expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "whatsapp"))).toBe(true); + }); + it("aborts when integrity drift callback rejects the fetched artifact", async () => { mockNpmViewMetadataResult(runCommandWithTimeoutMock, { name: "@openclaw/voice-call", @@ -689,7 +736,6 @@ describe("installPluginFromNpmSpec", () => { expectNpmInstallIntoRoot({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, - spec: "@openclaw/voice-call@beta", }); }); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index a8fb30d1144..2b2bf71d6a6 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1225,7 +1225,6 @@ export async function installPluginFromNpmSpec( }), "--prefix", npmRoot, - spec, ], { timeoutMs: Math.max(timeoutMs, 300_000),