diff --git a/src/cli/update-cli/post-core-plugin-convergence.test.ts b/src/cli/update-cli/post-core-plugin-convergence.test.ts index 502238ec659e..5b302f5cf056 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.test.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.test.ts @@ -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(); 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(); + 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 } } }, diff --git a/src/cli/update-cli/post-core-plugin-convergence.ts b/src/cli/update-cli/post-core-plugin-convergence.ts index 7a0ef09d01c4..8d5f09dfe41d 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.ts @@ -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,