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. - 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/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. - 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. - 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. - 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"]; 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( const installCalls = params.calls.filter(
(call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "install", (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", "--no-fund",
"--prefix", "--prefix",
params.npmRoot, params.npmRoot,
params.spec,
]); ]);
} }
@@ -150,7 +149,6 @@ function mockNpmViewAndInstallMany(
peerDependencies?: Record<string, string>; peerDependencies?: Record<string, string>;
}>, }>,
) { ) {
const packagesBySpec = new Map(packages.map((pkg) => [pkg.spec, pkg]));
const packagesByName = new Map(packages.map((pkg) => [pkg.packageName, pkg])); const packagesByName = new Map(packages.map((pkg) => [pkg.packageName, pkg]));
runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => { runCommandWithTimeoutMock.mockImplementation(async (argv: string[]) => {
const viewPackage = packages.find( const viewPackage = packages.find(
@@ -169,12 +167,21 @@ function mockNpmViewAndInstallMany(
); );
} }
if (argv[0] === "npm" && argv[1] === "install") { if (argv[0] === "npm" && argv[1] === "install") {
const spec = argv.at(-1); const prefixIndex = argv.indexOf("--prefix");
const pkg = spec ? packagesBySpec.get(spec) : undefined; const npmRoot = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined;
if (!pkg) { if (!npmRoot) {
throw new Error(`unexpected npm install spec: ${spec ?? ""}`); 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(); return successfulSpawn();
} }
if (argv[0] === "npm" && argv[1] === "uninstall") { if (argv[0] === "npm" && argv[1] === "uninstall") {
@@ -236,7 +243,6 @@ describe("installPluginFromNpmSpec", () => {
expectNpmInstallIntoRoot({ expectNpmInstallIntoRoot({
calls: runCommandWithTimeoutMock.mock.calls, calls: runCommandWithTimeoutMock.mock.calls,
npmRoot, npmRoot,
spec: "@openclaw/voice-call@0.0.1",
}); });
}); });
@@ -348,7 +354,6 @@ describe("installPluginFromNpmSpec", () => {
expectNpmInstallIntoRoot({ expectNpmInstallIntoRoot({
calls: runCommandWithTimeoutMock.mock.calls, calls: runCommandWithTimeoutMock.mock.calls,
npmRoot, npmRoot,
spec: "dangerous-plugin@1.0.0",
}); });
}); });
@@ -525,7 +530,6 @@ describe("installPluginFromNpmSpec", () => {
expectNpmInstallIntoRoot({ expectNpmInstallIntoRoot({
calls: runCommandWithTimeoutMock.mock.calls, calls: runCommandWithTimeoutMock.mock.calls,
npmRoot, npmRoot,
spec,
}); });
}, },
); );
@@ -599,10 +603,53 @@ describe("installPluginFromNpmSpec", () => {
expectNpmInstallIntoRoot({ expectNpmInstallIntoRoot({
calls: runCommandWithTimeoutMock.mock.calls, calls: runCommandWithTimeoutMock.mock.calls,
npmRoot, 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 () => { it("aborts when integrity drift callback rejects the fetched artifact", async () => {
mockNpmViewMetadataResult(runCommandWithTimeoutMock, { mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
name: "@openclaw/voice-call", name: "@openclaw/voice-call",
@@ -689,7 +736,6 @@ describe("installPluginFromNpmSpec", () => {
expectNpmInstallIntoRoot({ expectNpmInstallIntoRoot({
calls: runCommandWithTimeoutMock.mock.calls, calls: runCommandWithTimeoutMock.mock.calls,
npmRoot, npmRoot,
spec: "@openclaw/voice-call@beta",
}); });
}); });
}); });

View File

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