Skip to content

Commit 4a360ac

Browse files
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 8bcbf68
1 parent 3eb2d64 commit 4a360ac

8 files changed

Lines changed: 609 additions & 8 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
- fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.
1515
- doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.
16+
- 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.
1617
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
1718
- 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)
1819
- 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.

src/cli/update-cli/post-core-plugin-convergence.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
25

36
const mocks = vi.hoisted(() => ({
47
repairMissingConfiguredPluginInstalls: vi.fn(),
@@ -21,6 +24,8 @@ import {
2124
} from "./post-core-plugin-convergence.js";
2225

2326
describe("runPostCorePluginConvergence", () => {
27+
const tempDirs: string[] = [];
28+
2429
beforeEach(() => {
2530
vi.clearAllMocks();
2631
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
@@ -31,6 +36,43 @@ describe("runPostCorePluginConvergence", () => {
3136
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({ checked: [], failures: [] });
3237
});
3338

39+
afterEach(() => {
40+
for (const dir of tempDirs.splice(0)) {
41+
fs.rmSync(dir, { recursive: true, force: true });
42+
}
43+
});
44+
45+
function makeTempDir(): string {
46+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-post-core-convergence-"));
47+
tempDirs.push(dir);
48+
return dir;
49+
}
50+
51+
function writeBundledPlugin(rootDir: string, pluginId: string): string {
52+
const pluginDir = path.join(rootDir, pluginId);
53+
fs.mkdirSync(pluginDir, { recursive: true });
54+
fs.writeFileSync(path.join(pluginDir, "index.js"), "export default {};\n", "utf8");
55+
fs.writeFileSync(
56+
path.join(pluginDir, "openclaw.plugin.json"),
57+
JSON.stringify({
58+
id: pluginId,
59+
name: pluginId,
60+
version: "2026.5.20-beta.1",
61+
configSchema: { type: "object" },
62+
}),
63+
"utf8",
64+
);
65+
fs.writeFileSync(
66+
path.join(pluginDir, "package.json"),
67+
JSON.stringify({
68+
name: `@openclaw/${pluginId}`,
69+
version: "2026.5.20-beta.1",
70+
}),
71+
"utf8",
72+
);
73+
return pluginDir;
74+
}
75+
3476
it("calls repair with OPENCLAW_UPDATE_POST_CORE_CONVERGENCE=1 set", async () => {
3577
const cfg = { plugins: { entries: {} } } as unknown as OpenClawConfig;
3678
await runPostCorePluginConvergence({
@@ -121,6 +163,55 @@ describe("runPostCorePluginConvergence", () => {
121163
});
122164
});
123165

166+
it("prunes stale local bundled plugin shadows from baseline records before repair", async () => {
167+
const bundledRoot = makeTempDir();
168+
writeBundledPlugin(bundledRoot, "discord");
169+
const baseline = {
170+
discord: {
171+
source: "path" as const,
172+
installPath: path.join(makeTempDir(), "dist", "extensions", "discord"),
173+
version: "2026.5.4-beta.3",
174+
},
175+
brave: { source: "npm" as const, installPath: "/p/brave" },
176+
};
177+
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
178+
changes: [],
179+
warnings: [],
180+
records: { brave: baseline.brave },
181+
});
182+
const cfg = {
183+
plugins: { entries: { discord: { enabled: true }, brave: { enabled: true } } },
184+
} as unknown as OpenClawConfig;
185+
186+
const result = await runPostCorePluginConvergence({
187+
cfg,
188+
env: {
189+
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
190+
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
191+
VITEST: "true",
192+
},
193+
baselineInstallRecords: baseline,
194+
});
195+
196+
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({
197+
cfg,
198+
env: {
199+
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
200+
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
201+
VITEST: "true",
202+
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
203+
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
204+
},
205+
baselineRecords: {
206+
brave: baseline.brave,
207+
},
208+
});
209+
expect(result.changes).toEqual([
210+
'Removed stale local bundled plugin install record "discord".',
211+
]);
212+
expect(result.installRecords).toEqual({ brave: baseline.brave });
213+
});
214+
124215
it("flags errored=true and surfaces actionable guidance when repair warns", async () => {
125216
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
126217
changes: [],

src/cli/update-cli/post-core-plugin-convergence.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/u
33
import type { OpenClawConfig } from "../../config/types.openclaw.js";
44
import type { PluginInstallRecord } from "../../config/types.plugins.js";
55
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
6+
import { pruneStaleLocalBundledPluginInstallRecords } from "../../plugins/stale-local-bundled-plugin-install-records.js";
67
import {
78
resolveTrustedSourceLinkedOfficialClawHubSpec,
89
resolveTrustedSourceLinkedOfficialNpmSpec,
@@ -66,11 +67,17 @@ export async function runPostCorePluginConvergence(params: {
6667
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
6768
[UPDATE_POST_CORE_CONVERGENCE_ENV]: "1",
6869
};
70+
const prunedBaseline = params.baselineInstallRecords
71+
? pruneStaleLocalBundledPluginInstallRecords({
72+
installRecords: params.baselineInstallRecords,
73+
env,
74+
})
75+
: null;
6976

7077
const repair = await repairMissingConfiguredPluginInstalls({
7178
cfg: params.cfg,
7279
env,
73-
...(params.baselineInstallRecords ? { baselineRecords: params.baselineInstallRecords } : {}),
80+
...(prunedBaseline ? { baselineRecords: prunedBaseline.records } : {}),
7481
});
7582

7683
const warnings: PostCoreConvergenceWarning[] = repair.warnings.map((message) => ({
@@ -99,7 +106,12 @@ export async function runPostCorePluginConvergence(params: {
99106
}
100107

101108
return {
102-
changes: repair.changes,
109+
changes: [
110+
...(prunedBaseline?.stale.map(
111+
(record) => `Removed stale local bundled plugin install record "${record.pluginId}".`,
112+
) ?? []),
113+
...repair.changes,
114+
],
103115
warnings,
104116
errored: warnings.length > 0,
105117
smokeFailures: smoke.failures,

src/commands/doctor-plugin-registry.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,23 @@ function createCurrentIndexWithNpmRecord(params: {
222222
};
223223
}
224224

225+
function createCurrentIndexWithPathRecord(params: {
226+
pluginId: string;
227+
installPath: string;
228+
version?: string;
229+
}): InstalledPluginIndex {
230+
return {
231+
...createCurrentIndex(),
232+
installRecords: {
233+
[params.pluginId]: {
234+
source: "path",
235+
installPath: params.installPath,
236+
...(params.version ? { version: params.version } : {}),
237+
},
238+
},
239+
};
240+
}
241+
225242
function expectedPluginIndexRecord(params: {
226243
rootDir: string;
227244
pluginId: string;
@@ -462,6 +479,113 @@ describe("maybeRepairPluginRegistryState", () => {
462479
]);
463480
});
464481

482+
it("warns about stale local bundled plugin install records that shadow bundled plugins", async () => {
483+
const stateDir = makeTempDir();
484+
const bundledDir = path.join(stateDir, "current", "dist", "extensions", "discord");
485+
const staleDir = path.join(stateDir, "old-checkout", "dist", "extensions", "discord");
486+
fs.mkdirSync(bundledDir, { recursive: true });
487+
fs.mkdirSync(staleDir, { recursive: true });
488+
createCandidate(staleDir, "discord");
489+
await writePersistedInstalledPluginIndex(
490+
createCurrentIndexWithPathRecord({
491+
pluginId: "discord",
492+
installPath: staleDir,
493+
version: "2026.5.4-beta.3",
494+
}),
495+
{ stateDir },
496+
);
497+
498+
await maybeRepairPluginRegistryState({
499+
stateDir,
500+
candidates: [
501+
createBundledCandidate({
502+
rootDir: bundledDir,
503+
id: "discord",
504+
packageName: "@openclaw/discord",
505+
version: "2026.5.20-beta.1",
506+
}),
507+
],
508+
env: hermeticEnv(),
509+
config: {
510+
plugins: {
511+
allow: ["discord"],
512+
entries: {
513+
discord: {
514+
enabled: true,
515+
config: {},
516+
},
517+
},
518+
},
519+
},
520+
prompter: { shouldRepair: false },
521+
});
522+
523+
const notes = vi.mocked(note).mock.calls.join("\n");
524+
expect(notes).toContain("Local bundled plugin install records shadow bundled plugins");
525+
expect(notes).toContain("discord");
526+
expect(notes).toContain(staleDir);
527+
const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir);
528+
expect(persisted.installRecords).toHaveProperty("discord");
529+
});
530+
531+
it("removes stale local bundled plugin install records during repair", async () => {
532+
const stateDir = makeTempDir();
533+
const bundledDir = path.join(stateDir, "current", "dist", "extensions", "discord");
534+
const staleDir = path.join(stateDir, "old-checkout", "dist", "extensions", "discord");
535+
fs.mkdirSync(bundledDir, { recursive: true });
536+
fs.mkdirSync(staleDir, { recursive: true });
537+
createCandidate(staleDir, "discord");
538+
await writePersistedInstalledPluginIndex(
539+
createCurrentIndexWithPathRecord({
540+
pluginId: "discord",
541+
installPath: staleDir,
542+
version: "2026.5.4-beta.3",
543+
}),
544+
{ stateDir },
545+
);
546+
547+
await maybeRepairPluginRegistryState({
548+
stateDir,
549+
candidates: [
550+
createBundledCandidate({
551+
rootDir: bundledDir,
552+
id: "discord",
553+
packageName: "@openclaw/discord",
554+
version: "2026.5.20-beta.1",
555+
}),
556+
],
557+
env: hermeticEnv(),
558+
config: {
559+
plugins: {
560+
allow: ["discord"],
561+
entries: {
562+
discord: {
563+
enabled: true,
564+
config: {},
565+
},
566+
},
567+
},
568+
},
569+
prompter: { shouldRepair: true },
570+
});
571+
572+
const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir);
573+
expect(persisted.installRecords).toStrictEqual({});
574+
expect(persisted.refreshReason).toBe("migration");
575+
expect(persisted.plugins).toStrictEqual([
576+
expectedPluginIndexRecord({
577+
pluginId: "discord",
578+
rootDir: bundledDir,
579+
origin: "bundled",
580+
packageName: "@openclaw/discord",
581+
packageVersion: "2026.5.20-beta.1",
582+
}),
583+
]);
584+
expect(vi.mocked(note).mock.calls.join("\n")).toContain(
585+
"Removed stale local bundled plugin install record",
586+
);
587+
});
588+
465589
it("removes stale managed npm packages from the package lock during repair", async () => {
466590
const stateDir = makeTempDir();
467591
const bundledDir = path.join(stateDir, "bundled", "google-meet");

0 commit comments

Comments
 (0)