fix: isolate npm plugin installs per package (#87647)

* fix: isolate npm plugin installs per package

* test: assert isolated npm plugin projects in upgrade survivor

* test: assert plugin lifecycle npm project roots

* test: resolve npm project deps in live assertions

* fix: resolve codex bins from isolated npm projects

* docs: document isolated npm plugin projects

* ci: configure testbox workflow for crabbox

* fix: stabilize npm project fingerprint

* fix: keep fetch runtime import side-effect free

* test: keep dynamic live model unit hermetic

* ci: handle empty node toolcache roots

* test: make nounset toolcache probe deterministic
This commit is contained in:
Peter Steinberger
2026-05-28 21:16:07 +01:00
committed by GitHub
parent 2b587be44d
commit ea682182d0
37 changed files with 981 additions and 295 deletions
@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listManagedPluginNpmRoots: vi.fn(),
repairMissingConfiguredPluginInstalls: vi.fn(),
relinkOpenClawPeerDependenciesInManagedNpmRoot: vi.fn(),
runPluginPayloadSmokeCheck: vi.fn(),
@@ -16,6 +17,9 @@ vi.mock("../../plugins/plugin-peer-link.js", () => ({
relinkOpenClawPeerDependenciesInManagedNpmRoot:
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot,
}));
vi.mock("../../plugins/npm-project-roots.js", () => ({
listManagedPluginNpmRoots: mocks.listManagedPluginNpmRoots,
}));
vi.mock("./plugin-payload-validation.js", () => ({
runPluginPayloadSmokeCheck: mocks.runPluginPayloadSmokeCheck,
}));
@@ -33,6 +37,9 @@ describe("runPostCorePluginConvergence", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.listManagedPluginNpmRoots.mockImplementation((npmRoot: string) =>
Promise.resolve([npmRoot]),
);
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
changes: [],
warnings: [],
@@ -148,28 +155,43 @@ describe("runPostCorePluginConvergence", () => {
});
});
it("repairs managed npm openclaw peer links before payload smoke checks", async () => {
it("repairs managed npm openclaw peer links in every managed npm project before payload smoke checks", async () => {
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
changes: [],
warnings: [],
records: { codex: { source: "npm", installPath: "/p/codex" } },
});
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockResolvedValue({
checked: 1,
attempted: 1,
repaired: 1,
skipped: 0,
});
mocks.listManagedPluginNpmRoots.mockResolvedValue([
"/tmp/openclaw-state/npm",
"/tmp/openclaw-state/npm/projects/codex",
]);
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot
.mockResolvedValueOnce({
checked: 0,
attempted: 0,
repaired: 0,
skipped: 0,
})
.mockResolvedValueOnce({
checked: 1,
attempted: 1,
repaired: 1,
skipped: 0,
});
const result = await runPostCorePluginConvergence({
cfg: { plugins: { entries: { codex: { enabled: true } } } } as unknown as OpenClawConfig,
env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" },
});
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenCalledWith({
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenNthCalledWith(1, {
npmRoot: "/tmp/openclaw-state/npm",
logger: {},
});
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenNthCalledWith(2, {
npmRoot: "/tmp/openclaw-state/npm/projects/codex",
logger: {},
});
expect(result.changes).toEqual([
"Repaired OpenClaw host peer link(s) for 1 managed npm plugin package(s).",
]);
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
import { resolveDefaultPluginNpmDir } from "../../plugins/install-paths.js";
import { listManagedPluginNpmRoots } from "../../plugins/npm-project-roots.js";
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "../../plugins/plugin-peer-link.js";
import { pruneStaleLocalBundledPluginInstallRecords } from "../../plugins/stale-local-bundled-plugin-install-records.js";
import {
@@ -49,16 +50,20 @@ async function repairManagedNpmOpenClawPeerLinks(params: {
env: NodeJS.ProcessEnv;
}): Promise<{ changes: string[]; warnings: PostCoreConvergenceWarning[] }> {
try {
const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({
npmRoot: resolveDefaultPluginNpmDir(params.env),
logger: {},
});
const npmRoots = await listManagedPluginNpmRoots(resolveDefaultPluginNpmDir(params.env));
const results = await Promise.all(
npmRoots.map((npmRoot) =>
relinkOpenClawPeerDependenciesInManagedNpmRoot({
npmRoot,
logger: {},
}),
),
);
const repaired = results.reduce((total, result) => total + result.repaired, 0);
return {
changes:
result.repaired > 0
? [
`Repaired OpenClaw host peer link(s) for ${result.repaired} managed npm plugin package(s).`,
]
repaired > 0
? [`Repaired OpenClaw host peer link(s) for ${repaired} managed npm plugin package(s).`]
: [],
warnings: [],
};