From 4a360ac1cc182a61b4fa2d180caac9119983a248 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Thu, 21 May 2026 01:49:19 -0600 Subject: [PATCH] fix(update): prune stale local bundled plugin shadows Summary:\n- prune stale local bundled plugin path records during update/doctor repair\n- keep current, same-version, versionless, source-checkout, and arbitrary local path records preserved\n- add changelog and deterministic sort comparator cleanup\n\nVerification:\n- node scripts/run-vitest.mjs src/plugins/contracts/boundary-invariants.test.ts src/plugins/stale-local-bundled-plugin-install-records.test.ts src/cli/update-cli/post-core-plugin-convergence.test.ts src/commands/doctor-plugin-registry.test.ts\n- node scripts/run-oxlint-shards.mjs --threads=8\n- ./node_modules/.bin/oxfmt --check --threads=1 CHANGELOG.md src/plugins/stale-local-bundled-plugin-install-records.ts src/commands/doctor-plugin-registry.ts\n- git diff --check\n- GitHub exact-SHA: Real behavior proof, build-artifacts, checks-fast-contracts-plugins-a, check-prod-types, check-lint, check-test-types green on 8bcbf681ec66b3db71cd6a7c2c6a34ecb439d9b8 --- CHANGELOG.md | 1 + .../post-core-plugin-convergence.test.ts | 93 ++++++++++- .../post-core-plugin-convergence.ts | 16 +- src/commands/doctor-plugin-registry.test.ts | 124 ++++++++++++++ src/commands/doctor-plugin-registry.ts | 98 ++++++++++- .../shared/plugin-registry-migration.ts | 4 +- ...cal-bundled-plugin-install-records.test.ts | 158 ++++++++++++++++++ ...le-local-bundled-plugin-install-records.ts | 123 ++++++++++++++ 8 files changed, 609 insertions(+), 8 deletions(-) create mode 100644 src/plugins/stale-local-bundled-plugin-install-records.test.ts create mode 100644 src/plugins/stale-local-bundled-plugin-install-records.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb1b5332d6..b8cc7bc8fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987. - doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987. +- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev. - Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle. - Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408) - Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH. 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 9f1b52cfbc8..4c90812df32 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.test.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ repairMissingConfiguredPluginInstalls: vi.fn(), @@ -21,6 +24,8 @@ import { } from "./post-core-plugin-convergence.js"; describe("runPostCorePluginConvergence", () => { + const tempDirs: string[] = []; + beforeEach(() => { vi.clearAllMocks(); mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ @@ -31,6 +36,43 @@ describe("runPostCorePluginConvergence", () => { mocks.runPluginPayloadSmokeCheck.mockResolvedValue({ checked: [], failures: [] }); }); + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-post-core-convergence-")); + tempDirs.push(dir); + return dir; + } + + function writeBundledPlugin(rootDir: string, pluginId: string): string { + const pluginDir = path.join(rootDir, pluginId); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: pluginId, + name: pluginId, + version: "2026.5.20-beta.1", + configSchema: { type: "object" }, + }), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: `@openclaw/${pluginId}`, + version: "2026.5.20-beta.1", + }), + "utf8", + ); + return pluginDir; + } + it("calls repair with OPENCLAW_UPDATE_POST_CORE_CONVERGENCE=1 set", async () => { const cfg = { plugins: { entries: {} } } as unknown as OpenClawConfig; await runPostCorePluginConvergence({ @@ -121,6 +163,55 @@ describe("runPostCorePluginConvergence", () => { }); }); + it("prunes stale local bundled plugin shadows from baseline records before repair", async () => { + const bundledRoot = makeTempDir(); + writeBundledPlugin(bundledRoot, "discord"); + const baseline = { + discord: { + source: "path" as const, + installPath: path.join(makeTempDir(), "dist", "extensions", "discord"), + version: "2026.5.4-beta.3", + }, + brave: { source: "npm" as const, installPath: "/p/brave" }, + }; + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ + changes: [], + warnings: [], + records: { brave: baseline.brave }, + }); + const cfg = { + plugins: { entries: { discord: { enabled: true }, brave: { enabled: true } } }, + } as unknown as OpenClawConfig; + + const result = await runPostCorePluginConvergence({ + cfg, + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + VITEST: "true", + }, + baselineInstallRecords: baseline, + }); + + expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({ + cfg, + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + VITEST: "true", + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, + OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", + }, + baselineRecords: { + brave: baseline.brave, + }, + }); + expect(result.changes).toEqual([ + 'Removed stale local bundled plugin install record "discord".', + ]); + expect(result.installRecords).toEqual({ brave: baseline.brave }); + }); + it("flags errored=true and surfaces actionable guidance when repair warns", async () => { mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], diff --git a/src/cli/update-cli/post-core-plugin-convergence.ts b/src/cli/update-cli/post-core-plugin-convergence.ts index 278e3dd4896..16d75a00806 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.ts @@ -3,6 +3,7 @@ import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/u 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 { pruneStaleLocalBundledPluginInstallRecords } from "../../plugins/stale-local-bundled-plugin-install-records.js"; import { resolveTrustedSourceLinkedOfficialClawHubSpec, resolveTrustedSourceLinkedOfficialNpmSpec, @@ -66,11 +67,17 @@ export async function runPostCorePluginConvergence(params: { OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, [UPDATE_POST_CORE_CONVERGENCE_ENV]: "1", }; + const prunedBaseline = params.baselineInstallRecords + ? pruneStaleLocalBundledPluginInstallRecords({ + installRecords: params.baselineInstallRecords, + env, + }) + : null; const repair = await repairMissingConfiguredPluginInstalls({ cfg: params.cfg, env, - ...(params.baselineInstallRecords ? { baselineRecords: params.baselineInstallRecords } : {}), + ...(prunedBaseline ? { baselineRecords: prunedBaseline.records } : {}), }); const warnings: PostCoreConvergenceWarning[] = repair.warnings.map((message) => ({ @@ -99,7 +106,12 @@ export async function runPostCorePluginConvergence(params: { } return { - changes: repair.changes, + changes: [ + ...(prunedBaseline?.stale.map( + (record) => `Removed stale local bundled plugin install record "${record.pluginId}".`, + ) ?? []), + ...repair.changes, + ], warnings, errored: warnings.length > 0, smokeFailures: smoke.failures, diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index 6986ec94c64..2bcf23483c3 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -222,6 +222,23 @@ function createCurrentIndexWithNpmRecord(params: { }; } +function createCurrentIndexWithPathRecord(params: { + pluginId: string; + installPath: string; + version?: string; +}): InstalledPluginIndex { + return { + ...createCurrentIndex(), + installRecords: { + [params.pluginId]: { + source: "path", + installPath: params.installPath, + ...(params.version ? { version: params.version } : {}), + }, + }, + }; +} + function expectedPluginIndexRecord(params: { rootDir: string; pluginId: string; @@ -462,6 +479,113 @@ describe("maybeRepairPluginRegistryState", () => { ]); }); + it("warns about stale local bundled plugin install records that shadow bundled plugins", async () => { + const stateDir = makeTempDir(); + const bundledDir = path.join(stateDir, "current", "dist", "extensions", "discord"); + const staleDir = path.join(stateDir, "old-checkout", "dist", "extensions", "discord"); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.mkdirSync(staleDir, { recursive: true }); + createCandidate(staleDir, "discord"); + await writePersistedInstalledPluginIndex( + createCurrentIndexWithPathRecord({ + pluginId: "discord", + installPath: staleDir, + version: "2026.5.4-beta.3", + }), + { stateDir }, + ); + + await maybeRepairPluginRegistryState({ + stateDir, + candidates: [ + createBundledCandidate({ + rootDir: bundledDir, + id: "discord", + packageName: "@openclaw/discord", + version: "2026.5.20-beta.1", + }), + ], + env: hermeticEnv(), + config: { + plugins: { + allow: ["discord"], + entries: { + discord: { + enabled: true, + config: {}, + }, + }, + }, + }, + prompter: { shouldRepair: false }, + }); + + const notes = vi.mocked(note).mock.calls.join("\n"); + expect(notes).toContain("Local bundled plugin install records shadow bundled plugins"); + expect(notes).toContain("discord"); + expect(notes).toContain(staleDir); + const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir); + expect(persisted.installRecords).toHaveProperty("discord"); + }); + + it("removes stale local bundled plugin install records during repair", async () => { + const stateDir = makeTempDir(); + const bundledDir = path.join(stateDir, "current", "dist", "extensions", "discord"); + const staleDir = path.join(stateDir, "old-checkout", "dist", "extensions", "discord"); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.mkdirSync(staleDir, { recursive: true }); + createCandidate(staleDir, "discord"); + await writePersistedInstalledPluginIndex( + createCurrentIndexWithPathRecord({ + pluginId: "discord", + installPath: staleDir, + version: "2026.5.4-beta.3", + }), + { stateDir }, + ); + + await maybeRepairPluginRegistryState({ + stateDir, + candidates: [ + createBundledCandidate({ + rootDir: bundledDir, + id: "discord", + packageName: "@openclaw/discord", + version: "2026.5.20-beta.1", + }), + ], + env: hermeticEnv(), + config: { + plugins: { + allow: ["discord"], + entries: { + discord: { + enabled: true, + config: {}, + }, + }, + }, + }, + prompter: { shouldRepair: true }, + }); + + const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir); + expect(persisted.installRecords).toStrictEqual({}); + expect(persisted.refreshReason).toBe("migration"); + expect(persisted.plugins).toStrictEqual([ + expectedPluginIndexRecord({ + pluginId: "discord", + rootDir: bundledDir, + origin: "bundled", + packageName: "@openclaw/discord", + packageVersion: "2026.5.20-beta.1", + }), + ]); + expect(vi.mocked(note).mock.calls.join("\n")).toContain( + "Removed stale local bundled plugin install record", + ); + }); + it("removes stale managed npm packages from the package lock during repair", async () => { const stateDir = makeTempDir(); const bundledDir = path.join(stateDir, "bundled", "google-meet"); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index 53bbde474ff..9b5085e52c7 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -4,6 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { saveJsonFile } from "../infra/json-file.js"; import { tryReadJsonSync } from "../infra/json-files.js"; +import type { BundledPluginSource } from "../plugins/bundled-sources.js"; import { resolveDefaultPluginNpmDir } from "../plugins/install-paths.js"; import { loadInstalledPluginIndexInstallRecords, @@ -15,6 +16,10 @@ import { relinkOpenClawPeerDependenciesInManagedNpmRoot, } from "../plugins/plugin-peer-link.js"; import { refreshPluginRegistry } from "../plugins/plugin-registry.js"; +import { + listStaleLocalBundledPluginInstallRecords, + type StaleLocalBundledPluginInstallRecord, +} from "../plugins/stale-local-bundled-plugin-install-records.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -107,7 +112,9 @@ function listStaleManagedNpmBundledPlugins( const dependencies = readStringMap(readJsonObject(npmPackageJsonPath)?.dependencies); const stale: StaleManagedNpmBundledPlugin[] = []; - for (const packageName of Object.keys(dependencies).toSorted()) { + for (const packageName of Object.keys(dependencies).toSorted((left, right) => + left.localeCompare(right), + )) { if (!packageName.startsWith("@openclaw/")) { continue; } @@ -132,6 +139,40 @@ function listStaleManagedNpmBundledPlugins( return stale; } +function loadCurrentBundledPluginSources( + params: PluginRegistryDoctorRepairParams, +): Map { + const currentBundled = loadInstalledPluginIndex({ + ...params, + installRecords: {}, + }).plugins.filter((plugin) => plugin.origin === "bundled"); + return new Map( + currentBundled.map( + (plugin) => + [ + plugin.pluginId, + { + pluginId: plugin.pluginId, + localPath: plugin.rootDir, + ...(plugin.packageName ? { npmSpec: plugin.packageName } : {}), + ...(plugin.packageVersion ? { version: plugin.packageVersion } : {}), + }, + ] as const, + ), + ); +} + +async function listStaleLocalBundledPluginInstallRecordShadows( + params: PluginRegistryDoctorRepairParams, +): Promise { + return listStaleLocalBundledPluginInstallRecords({ + installRecords: await loadInstalledPluginIndexInstallRecords(params), + workspaceDir: params.workspaceDir, + env: params.env, + bundled: loadCurrentBundledPluginSources(params), + }); +} + function removeManagedNpmDependency(params: { npmRoot: string; packageName: string; @@ -241,6 +282,36 @@ export function maybeRepairStaleManagedNpmBundledPlugins( return true; } +export async function maybeRepairStaleLocalBundledPluginInstallRecords( + params: PluginRegistryDoctorRepairParams, +): Promise { + const stale = await listStaleLocalBundledPluginInstallRecordShadows(params); + if (stale.length === 0) { + return []; + } + + if (!params.prompter.shouldRepair) { + note( + [ + "Local bundled plugin install records shadow bundled plugins:", + ...stale.map((record) => `- ${record.pluginId}: ${shortenHomePath(record.stalePath)}`), + `Repair with ${formatCliCommand("openclaw doctor --fix")} to remove stale local install records and rebuild the plugin registry.`, + ].join("\n"), + "Plugin registry", + ); + return []; + } + + note( + [ + "Removed stale local bundled plugin install record(s) shadowing bundled plugins:", + ...stale.map((record) => `- ${record.pluginId}: ${shortenHomePath(record.stalePath)}`), + ].join("\n"), + "Plugin registry", + ); + return stale.map((record) => record.pluginId); +} + export async function maybeRepairManagedNpmOpenClawPeerLinks( params: PluginRegistryDoctorRepairParams, ): Promise { @@ -323,7 +394,15 @@ export async function maybeRepairPluginRegistryState( (plugin) => plugin.pluginId, ); const removedStaleManagedNpmBundledPlugins = maybeRepairStaleManagedNpmBundledPlugins(params); + const removedStaleLocalBundledPluginIds = + await maybeRepairStaleLocalBundledPluginInstallRecords(params); const repairedManagedNpmOpenClawPeerLinks = await maybeRepairManagedNpmOpenClawPeerLinks(params); + const stalePluginIdsToRemove = [ + ...new Set([ + ...(removedStaleManagedNpmBundledPlugins ? staleManagedNpmBundledPluginIds : []), + ...removedStaleLocalBundledPluginIds, + ]), + ]; if (!params.prompter.shouldRepair) { if (preflight.action === "migrate") { note( @@ -338,7 +417,17 @@ export async function maybeRepairPluginRegistryState( } if (preflight.action === "migrate") { - const result = await migratePluginRegistryForInstall(migrationParams); + const result = await migratePluginRegistryForInstall({ + ...migrationParams, + ...(stalePluginIdsToRemove.length > 0 + ? { + installRecords: await loadInstallRecordsWithoutPluginIds( + params, + stalePluginIdsToRemove, + ), + } + : {}), + }); if (result.migrated) { const total = result.current.plugins.length; const enabled = result.current.plugins.filter((plugin) => plugin.enabled).length; @@ -353,16 +442,17 @@ export async function maybeRepairPluginRegistryState( if ( preflight.action === "skip-existing" || removedStaleManagedNpmBundledPlugins || + removedStaleLocalBundledPluginIds.length > 0 || repairedManagedNpmOpenClawPeerLinks ) { const index = await refreshPluginRegistry({ ...migrationParams, reason: "migration", - ...(removedStaleManagedNpmBundledPlugins + ...(stalePluginIdsToRemove.length > 0 ? { installRecords: await loadInstallRecordsWithoutPluginIds( params, - staleManagedNpmBundledPluginIds, + stalePluginIdsToRemove, ), } : {}), diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts index 1f05da3289d..346d7d8e350 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -293,9 +293,11 @@ export async function migratePluginRegistryForInstall( const rawConfig = await readMigrationConfig(params); const config = stripShippedPluginInstallConfigRecords(rawConfig) as OpenClawConfig; + const durableInstallRecords = + params.installRecords ?? (await loadInstalledPluginIndexInstallRecords(params)); const installRecords = { ...extractShippedPluginInstallConfigRecords(rawConfig), - ...(await loadInstalledPluginIndexInstallRecords(params)), + ...durableInstallRecords, }; const migrationParams = { ...params, diff --git a/src/plugins/stale-local-bundled-plugin-install-records.test.ts b/src/plugins/stale-local-bundled-plugin-install-records.test.ts new file mode 100644 index 00000000000..4039d1fffbf --- /dev/null +++ b/src/plugins/stale-local-bundled-plugin-install-records.test.ts @@ -0,0 +1,158 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import type { BundledPluginSource } from "./bundled-sources.js"; +import { + listStaleLocalBundledPluginInstallRecords, + pruneStaleLocalBundledPluginInstallRecords, +} from "./stale-local-bundled-plugin-install-records.js"; + +function bundledSource(pluginId: string, localPath: string): Map { + return new Map([ + [ + pluginId, + { + pluginId, + localPath, + version: "2026.5.20", + }, + ], + ]); +} + +describe("listStaleLocalBundledPluginInstallRecords", () => { + it("lists path install records that point at stale compiled bundled output", () => { + const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord"); + const stalePath = path.join("/tmp/old-openclaw", "dist", "extensions", "discord"); + const records: Record = { + discord: { + source: "path", + installPath: stalePath, + version: "2026.5.4-beta.3", + }, + brave: { + source: "npm", + installPath: "/tmp/plugins/brave", + }, + }; + + expect( + listStaleLocalBundledPluginInstallRecords({ + installRecords: records, + bundled: bundledSource("discord", currentPath), + }), + ).toStrictEqual([ + { + pluginId: "discord", + record: records.discord, + recordPathField: "installPath", + stalePath, + bundledPath: currentPath, + }, + ]); + }); + + it("does not list the current bundled path", () => { + const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord"); + + expect( + listStaleLocalBundledPluginInstallRecords({ + installRecords: { + discord: { + source: "path", + installPath: currentPath, + version: "2026.5.4-beta.3", + }, + }, + bundled: bundledSource("discord", currentPath), + }), + ).toStrictEqual([]); + }); + + it("does not list compiled bundled paths without a stale version", () => { + const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord"); + + expect( + listStaleLocalBundledPluginInstallRecords({ + installRecords: { + discord: { + source: "path", + installPath: path.join("/tmp/local-openclaw", "dist", "extensions", "discord"), + }, + acpx: { + source: "path", + installPath: path.join("/tmp/local-openclaw", "dist", "extensions", "acpx"), + version: "2026.5.20", + }, + }, + bundled: new Map([ + ...bundledSource("discord", currentPath), + ...bundledSource("acpx", path.join("/opt/openclaw", "dist", "extensions", "acpx")), + ]), + }), + ).toStrictEqual([]); + }); + + it("does not list source checkout or arbitrary local plugin paths", () => { + const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord"); + + expect( + listStaleLocalBundledPluginInstallRecords({ + installRecords: { + discord: { + source: "path", + installPath: path.join("/tmp/openclaw", "extensions", "discord"), + version: "2026.5.4-beta.3", + }, + acpx: { + source: "path", + installPath: path.join("/tmp/custom-plugins", "acpx"), + version: "2026.5.4-beta.3", + }, + }, + bundled: new Map([ + ...bundledSource("discord", currentPath), + ...bundledSource("acpx", path.join("/opt/openclaw", "dist", "extensions", "acpx")), + ]), + }), + ).toStrictEqual([]); + }); +}); + +describe("pruneStaleLocalBundledPluginInstallRecords", () => { + it("removes only stale local bundled plugin install records", () => { + const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord"); + const stalePath = path.join("/tmp/old-openclaw", "dist", "extensions", "discord"); + const records: Record = { + discord: { + source: "path", + installPath: stalePath, + version: "2026.5.4-beta.3", + }, + brave: { + source: "npm", + installPath: "/tmp/plugins/brave", + }, + }; + + expect( + pruneStaleLocalBundledPluginInstallRecords({ + installRecords: records, + bundled: bundledSource("discord", currentPath), + }), + ).toStrictEqual({ + records: { + brave: records.brave, + }, + stale: [ + { + pluginId: "discord", + record: records.discord, + recordPathField: "installPath", + stalePath, + bundledPath: currentPath, + }, + ], + }); + }); +}); diff --git a/src/plugins/stale-local-bundled-plugin-install-records.ts b/src/plugins/stale-local-bundled-plugin-install-records.ts new file mode 100644 index 00000000000..f1c5c220a9a --- /dev/null +++ b/src/plugins/stale-local-bundled-plugin-install-records.ts @@ -0,0 +1,123 @@ +import path from "node:path"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { resolveUserPath } from "../utils.js"; +import { normalizeBundledLookupPath } from "./bundled-load-path-aliases.js"; +import { resolveBundledPluginSources, type BundledPluginSource } from "./bundled-sources.js"; + +export type StaleLocalBundledPluginInstallRecord = { + pluginId: string; + record: PluginInstallRecord; + recordPathField: "installPath" | "sourcePath"; + stalePath: string; + bundledPath: string; +}; + +function normalizePathForCompare(rawPath: string, env?: NodeJS.ProcessEnv): string { + return path.resolve(normalizeBundledLookupPath(resolveUserPath(rawPath, env))); +} + +function primaryInstallRecordPath(record: PluginInstallRecord): { + field: "installPath" | "sourcePath"; + path: string; +} | null { + if (typeof record.installPath === "string" && record.installPath.trim()) { + return { field: "installPath", path: record.installPath }; + } + if (typeof record.sourcePath === "string" && record.sourcePath.trim()) { + return { field: "sourcePath", path: record.sourcePath }; + } + return null; +} + +function looksLikeCompiledBundledPluginPath(targetPath: string, pluginId: string): boolean { + const segments = normalizeBundledLookupPath(targetPath).split(/[\\/]+/u); + return segments.some((segment, index) => { + return ( + (segment === "dist" || segment === "dist-runtime") && + segments[index + 1] === "extensions" && + segments[index + 2] === pluginId + ); + }); +} + +function hasStaleBundledVersion( + record: PluginInstallRecord, + bundledSource: BundledPluginSource, +): boolean { + const recordVersion = record.version?.trim(); + const bundledVersion = bundledSource.version?.trim(); + return Boolean(recordVersion && bundledVersion && recordVersion !== bundledVersion); +} + +export function listStaleLocalBundledPluginInstallRecords(params: { + installRecords: Record; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + bundled?: ReadonlyMap; +}): StaleLocalBundledPluginInstallRecord[] { + const bundled = + params.bundled ?? + resolveBundledPluginSources({ + workspaceDir: params.workspaceDir, + env: params.env, + }); + const stale: StaleLocalBundledPluginInstallRecord[] = []; + + for (const [pluginId, record] of Object.entries(params.installRecords).toSorted( + ([left], [right]) => left.localeCompare(right), + )) { + if (record.source !== "path") { + continue; + } + const bundledSource = bundled.get(pluginId); + if (!bundledSource?.localPath) { + continue; + } + if (!hasStaleBundledVersion(record, bundledSource)) { + continue; + } + const recordPath = primaryInstallRecordPath(record); + if (!recordPath) { + continue; + } + const stalePath = normalizePathForCompare(recordPath.path, params.env); + const bundledPath = normalizePathForCompare(bundledSource.localPath, params.env); + if (stalePath === bundledPath) { + continue; + } + if (!looksLikeCompiledBundledPluginPath(stalePath, pluginId)) { + continue; + } + stale.push({ + pluginId, + record, + recordPathField: recordPath.field, + stalePath, + bundledPath, + }); + } + + return stale; +} + +export function pruneStaleLocalBundledPluginInstallRecords(params: { + installRecords: Record; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + bundled?: ReadonlyMap; +}): { + records: Record; + stale: StaleLocalBundledPluginInstallRecord[]; +} { + const stale = listStaleLocalBundledPluginInstallRecords(params); + if (stale.length === 0) { + return { records: params.installRecords, stale }; + } + const staleIds = new Set(stale.map((record) => record.pluginId)); + return { + records: Object.fromEntries( + Object.entries(params.installRecords).filter(([pluginId]) => !staleIds.has(pluginId)), + ), + stale, + }; +}