fix: prevent startup failures from obsolete managed plugin shadows (#121261)

* fix: retire stale bundled plugin shadows after core updates

* test: type convergence call ordering
This commit is contained in:
Jason (Json)
2026-08-09 17:00:24 -06:00
committed by GitHub
parent eb59c68436
commit d9e43b147a
2 changed files with 97 additions and 6 deletions
@@ -7,6 +7,7 @@ import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"
const mocks = vi.hoisted(() => ({
listManagedPluginNpmRoots: vi.fn(),
maybeRepairStaleManagedNpmBundledPlugins: vi.fn(),
repairMissingConfiguredPluginInstalls: vi.fn(),
relinkOpenClawPeerDependenciesInManagedNpmRoot: vi.fn(),
runPluginPayloadSmokeCheck: vi.fn(),
@@ -15,6 +16,9 @@ const mocks = vi.hoisted(() => ({
vi.mock("../../commands/doctor/shared/missing-configured-plugin-install.js", () => ({
repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls,
}));
vi.mock("../../commands/doctor-plugin-registry.js", () => ({
maybeRepairStaleManagedNpmBundledPlugins: mocks.maybeRepairStaleManagedNpmBundledPlugins,
}));
vi.mock("../../plugins/plugin-peer-link.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../plugins/plugin-peer-link.js")>();
return {
@@ -23,15 +27,20 @@ vi.mock("../../plugins/plugin-peer-link.js", async (importOriginal) => {
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot,
};
});
vi.mock("../../plugins/npm-project-roots.js", () => ({
listManagedPluginNpmRoots: mocks.listManagedPluginNpmRoots,
}));
vi.mock("../../plugins/npm-project-roots.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../plugins/npm-project-roots.js")>();
return {
...actual,
listManagedPluginNpmRoots: mocks.listManagedPluginNpmRoots,
};
});
vi.mock("./plugin-payload-validation.js", () => ({
runPluginPayloadSmokeCheck: mocks.runPluginPayloadSmokeCheck,
}));
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { resolvePluginNpmGenerationProjectDir } from "../../plugins/install-paths.js";
import { VERSION } from "../../version.js";
import {
filterRecordsToActive,
@@ -50,6 +59,7 @@ describe("runPostCorePluginConvergence", () => {
mocks.listManagedPluginNpmRoots.mockImplementation((npmRoot: string) =>
Promise.resolve([npmRoot]),
);
mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(false);
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
changes: [],
warnings: [],
@@ -64,7 +74,11 @@ describe("runPostCorePluginConvergence", () => {
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({ checked: [], failures: [] });
});
function writeBundledPlugin(rootDir: string, pluginId: string): string {
function writeBundledPlugin(
rootDir: string,
pluginId: string,
version = "2026.5.20-beta.1",
): string {
const pluginDir = path.join(rootDir, pluginId);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, "index.js"), "export default {};\n", "utf8");
@@ -73,7 +87,7 @@ describe("runPostCorePluginConvergence", () => {
JSON.stringify({
id: pluginId,
name: pluginId,
version: "2026.5.20-beta.1",
version,
configSchema: { type: "object" },
}),
"utf8",
@@ -82,7 +96,7 @@ describe("runPostCorePluginConvergence", () => {
path.join(pluginDir, "package.json"),
JSON.stringify({
name: `@openclaw/${pluginId}`,
version: "2026.5.20-beta.1",
version,
}),
"utf8",
);
@@ -96,6 +110,15 @@ describe("runPostCorePluginConvergence", () => {
env: { OPENCLAW_UPDATE_IN_PROGRESS: "1" },
});
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledTimes(1);
expect(mocks.maybeRepairStaleManagedNpmBundledPlugins).toHaveBeenCalledWith({
config: cfg,
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
},
prompter: { shouldRepair: true },
});
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({
cfg,
env: {
@@ -104,6 +127,17 @@ describe("runPostCorePluginConvergence", () => {
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
},
});
expect(
expectDefined(
mocks.maybeRepairStaleManagedNpmBundledPlugins.mock.invocationCallOrder[0],
"stale managed cleanup call order",
),
).toBeLessThan(
expectDefined(
mocks.repairMissingConfiguredPluginInstalls.mock.invocationCallOrder[0],
"missing configured plugin repair call order",
),
);
});
it("checks active payloads without running repair or peer-link convergence", async () => {
@@ -372,6 +406,54 @@ describe("runPostCorePluginConvergence", () => {
expect(result.installRecords).toEqual({ brave: baseline.brave });
});
it("retires a stale managed generation when its official plugin is now bundled", async () => {
const stateDir = tempDirs.make("openclaw-post-core-convergence-");
const bundledRoot = tempDirs.make("openclaw-post-core-bundled-");
writeBundledPlugin(bundledRoot, "codex", VERSION);
const npmRoot = resolvePluginNpmGenerationProjectDir({
npmDir: path.join(stateDir, "npm"),
packageName: "@openclaw/codex",
generationKey: "@openclaw/codex@2026.7.2-beta.7",
});
const packageDir = path.join(npmRoot, "node_modules", "@openclaw", "codex");
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(
path.join(npmRoot, "package.json"),
JSON.stringify({ dependencies: { "@openclaw/codex": "2026.7.2-beta.7" } }),
"utf8",
);
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({ name: "@openclaw/codex", version: "2026.7.2-beta.7" }),
"utf8",
);
fs.writeFileSync(
path.join(packageDir, "openclaw.plugin.json"),
JSON.stringify({ id: "codex", name: "codex", configSchema: { type: "object" } }),
"utf8",
);
const actualDoctorRegistry = await vi.importActual<
typeof import("../../commands/doctor-plugin-registry.js")
>("../../commands/doctor-plugin-registry.js");
mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation(
actualDoctorRegistry.maybeRepairStaleManagedNpmBundledPlugins,
);
await runPostCorePluginConvergence({
cfg: {
plugins: { allow: ["codex"], entries: { codex: { enabled: true } } },
},
env: {
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
VITEST: "true",
},
});
expect(fs.existsSync(packageDir)).toBe(false);
});
it("forwards ClawHub risk acknowledgement options to repair", async () => {
const cfg = {
plugins: { entries: { matrix: { enabled: true } } },
@@ -169,6 +169,15 @@ export async function runPostCorePluginConvergence(params: {
OPENCLAW_COMPATIBILITY_HOST_VERSION: params.compatibilityHostVersion ?? VERSION,
[UPDATE_POST_CORE_CONVERGENCE_ENV]: "1",
};
// Retire obsolete managed shadows before relinking or smoke-checking them. A package that
// became bundled with the new core must not survive into the next startup's contract graph.
const { maybeRepairStaleManagedNpmBundledPlugins } =
await import("../../commands/doctor-plugin-registry.js");
maybeRepairStaleManagedNpmBundledPlugins({
config: params.cfg,
env,
prompter: { shouldRepair: true },
});
const prunedBaseline = params.baselineInstallRecords
? pruneStaleLocalBundledPluginInstallRecords({
installRecords: params.baselineInstallRecords,