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.
This commit is contained in:
byungskers
2026-05-03 20:51:50 +09:00
committed by GitHub
parent b8a4d6a58a
commit f7522edb96
3 changed files with 60 additions and 14 deletions

View File

@@ -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.

View File

@@ -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<string, string>;
}>,
) {
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<string, string>;
};
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",
});
});
});

View File

@@ -1225,7 +1225,6 @@ export async function installPluginFromNpmSpec(
}),
"--prefix",
npmRoot,
spec,
],
{
timeoutMs: Math.max(timeoutMs, 300_000),