Skip to content

Commit ab2943e

Browse files
authored
fix(update): repair configured plugins during legacy upgrades (#82859)
* fix(update): repair configured plugins during legacy upgrades * docs(changelog): note legacy plugin upgrade repair * test(update): use valid whatsapp upgrade fixture
1 parent 91ae1a6 commit ab2943e

11 files changed

Lines changed: 293 additions & 8 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ Docs: https://docs.openclaw.ai
174174
- Telegram: send the transcript-backed full final answer after progress-mode tool drafts when the dispatcher final payload is an ellipsis-truncated snapshot. Fixes #82409. Thanks @PashaGanson.
175175
- Providers/Ollama: omit truthy native `think` payloads for models marked non-reasoning while preserving supported thinking models and explicit `think: false`. (#82445) Thanks @leno23.
176176
- Update/channels: preserve pre-update channel config through package-swap doctor and post-core plugin repair so externalized channel upgrades do not drop configured chat channels. Fixes #82533. Thanks @imbaig.
177+
- Update/doctor: repair configured externalized plugin installs during legacy 2026.4.x upgrades so configured Discord channels remain available after 2026.5.x package updates. Fixes #82813. (#82859) Thanks @joshavant.
177178
- CLI/context engines: bootstrap and finalize non-legacy context engines for CLI turns while preserving transcript snapshots and deferred maintenance ownership. (#81869) Thanks @sahilsatralkar.
178179
- Telegram: persist polling updates through restart replay so queued same-topic messages resume in order instead of losing context after a gateway restart. (#82256) Thanks @VACInc.
179180
- Gateway/Gmail: abort in-flight Gmail watcher startup and hot-reload restarts before shutdown so reloads cannot spawn `gog serve` after the Gateway is closing. Thanks @frankekn.

src/cli/update-cli.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4535,6 +4535,7 @@ describe("update-cli", () => {
45354535
await withEnvAsync(
45364536
{
45374537
OPENCLAW_UPDATE_IN_PROGRESS: undefined,
4538+
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: undefined,
45384539
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: undefined,
45394540
},
45404541
async () => {
@@ -4547,8 +4548,10 @@ describe("update-cli", () => {
45474548
await updateFinalizeCommand({ json: true, yes: true, timeout: "9", restart: false });
45484549

45494550
expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
4551+
expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1");
45504552
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
45514553
expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined();
4554+
expect(process.env.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBeUndefined();
45524555
expect(process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBeUndefined();
45534556
expect(doctorCommand).toHaveBeenCalledWith(defaultRuntime, {
45544557
nonInteractive: true,

src/cli/update-cli/update-command.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ensureCompletionCacheExists,
1010
} from "../../commands/doctor-completion.js";
1111
import { doctorCommand } from "../../commands/doctor.js";
12+
import { UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV } from "../../commands/doctor/shared/update-phase.js";
1213
import { createPreUpdateConfigSnapshot } from "../../config/backup-rotation.js";
1314
import {
1415
assertConfigWriteAllowedInCurrentMode,
@@ -1264,6 +1265,7 @@ async function runPackageInstallUpdate(params: {
12641265
invocationCwd: params.invocationCwd,
12651266
}),
12661267
OPENCLAW_UPDATE_IN_PROGRESS: "1",
1268+
[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1",
12671269
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
12681270
},
12691271
timeoutMs: params.timeoutMs,
@@ -1336,6 +1338,7 @@ async function runGitUpdate(params: {
13361338
channel: params.channel,
13371339
tag: params.tag,
13381340
devTargetRef: params.devTargetRef,
1341+
deferConfiguredPluginInstallRepair: true,
13391342
});
13401343
const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps];
13411344

@@ -2018,16 +2021,25 @@ type UpdateFinalizeResult = {
20182021

20192022
function withUpdateFinalizationEnv<T>(run: () => Promise<T>): Promise<T> {
20202023
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
2024+
const previousDeferConfiguredPluginInstallRepair =
2025+
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
20212026
const previousParentSupportsDoctorConfigWrite =
20222027
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
20232028
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
2029+
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1";
20242030
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
20252031
return run().finally(() => {
20262032
if (previousUpdateInProgress === undefined) {
20272033
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
20282034
} else {
20292035
process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress;
20302036
}
2037+
if (previousDeferConfiguredPluginInstallRepair === undefined) {
2038+
delete process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
2039+
} else {
2040+
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] =
2041+
previousDeferConfiguredPluginInstallRepair;
2042+
}
20312043
if (previousParentSupportsDoctorConfigWrite === undefined) {
20322044
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
20332045
} else {

src/commands/doctor/shared/missing-configured-plugin-install.test.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
962962
},
963963
env: {
964964
OPENCLAW_UPDATE_IN_PROGRESS: "1",
965+
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
965966
},
966967
});
967968

@@ -1061,6 +1062,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
10611062
},
10621063
env: {
10631064
OPENCLAW_UPDATE_IN_PROGRESS: "1",
1065+
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
10641066
},
10651067
});
10661068

@@ -1077,7 +1079,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
10771079
});
10781080
});
10791081

1080-
it("does not install channel-selected external plugins during the package update doctor pass", async () => {
1082+
it("does not install channel-selected external plugins during an opted-in package update doctor pass", async () => {
10811083
mocks.listChannelPluginCatalogEntries.mockReturnValue([
10821084
{
10831085
id: "discord",
@@ -1099,6 +1101,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
10991101
},
11001102
env: {
11011103
OPENCLAW_UPDATE_IN_PROGRESS: "1",
1104+
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
11021105
},
11031106
});
11041107

@@ -1109,6 +1112,144 @@ describe("repairMissingConfiguredPluginInstalls", () => {
11091112
expect(result).toEqual({ changes: [], warnings: [], records: {} });
11101113
});
11111114

1115+
it("installs channel-selected external plugins during a legacy package update doctor pass", async () => {
1116+
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
1117+
ok: true,
1118+
pluginId: "discord",
1119+
targetDir: "/tmp/openclaw-plugins/discord",
1120+
version: "2026.5.17",
1121+
npmResolution: {
1122+
name: "@openclaw/discord",
1123+
version: "2026.5.17",
1124+
resolvedSpec: "@openclaw/[email protected]",
1125+
integrity: "sha512-discord",
1126+
resolvedAt: "2026-05-17T00:00:00.000Z",
1127+
},
1128+
});
1129+
mocks.listChannelPluginCatalogEntries.mockReturnValue([
1130+
{
1131+
id: "discord",
1132+
pluginId: "discord",
1133+
meta: { label: "Discord" },
1134+
install: {
1135+
npmSpec: "@openclaw/discord",
1136+
},
1137+
},
1138+
]);
1139+
1140+
const { repairMissingConfiguredPluginInstalls } =
1141+
await import("./missing-configured-plugin-install.js");
1142+
const result = await repairMissingConfiguredPluginInstalls({
1143+
cfg: {
1144+
channels: {
1145+
discord: { enabled: true, token: "secret" },
1146+
},
1147+
},
1148+
env: {
1149+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
1150+
},
1151+
});
1152+
1153+
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledTimes(1);
1154+
expect(result.changes).toEqual([
1155+
'Installed missing configured plugin "discord" from @openclaw/discord.',
1156+
]);
1157+
expectRecordFields(result.records.discord, {
1158+
source: "npm",
1159+
installPath: "/tmp/openclaw-plugins/discord",
1160+
});
1161+
});
1162+
1163+
it("prefers npm over ClawHub during a legacy package update doctor pass", async () => {
1164+
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
1165+
ok: true,
1166+
pluginId: "whatsapp",
1167+
targetDir: "/tmp/openclaw-plugins/whatsapp",
1168+
version: "2026.5.17",
1169+
npmResolution: {
1170+
name: "@openclaw/whatsapp",
1171+
version: "2026.5.17",
1172+
resolvedSpec: "@openclaw/[email protected]",
1173+
integrity: "sha512-whatsapp",
1174+
resolvedAt: "2026-05-17T00:00:00.000Z",
1175+
},
1176+
});
1177+
mocks.listChannelPluginCatalogEntries.mockReturnValue([
1178+
{
1179+
id: "whatsapp",
1180+
pluginId: "whatsapp",
1181+
meta: { label: "WhatsApp" },
1182+
install: {
1183+
clawhubSpec: "clawhub:@openclaw/whatsapp",
1184+
npmSpec: "@openclaw/whatsapp",
1185+
defaultChoice: "clawhub",
1186+
},
1187+
},
1188+
]);
1189+
1190+
const { repairMissingConfiguredPluginInstalls } =
1191+
await import("./missing-configured-plugin-install.js");
1192+
const result = await repairMissingConfiguredPluginInstalls({
1193+
cfg: {
1194+
channels: {
1195+
whatsapp: { enabled: true, allowFrom: ["+15555550123"] },
1196+
},
1197+
},
1198+
env: {
1199+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
1200+
},
1201+
});
1202+
1203+
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
1204+
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
1205+
spec: "@openclaw/whatsapp",
1206+
expectedPluginId: "whatsapp",
1207+
});
1208+
expect(result.changes).toEqual([
1209+
'Installed missing configured plugin "whatsapp" from @openclaw/whatsapp.',
1210+
]);
1211+
expectRecordFields(result.records.whatsapp, {
1212+
source: "npm",
1213+
installPath: "/tmp/openclaw-plugins/whatsapp",
1214+
});
1215+
});
1216+
1217+
it("keeps ClawHub-only candidates available during a legacy package update doctor pass", async () => {
1218+
mocks.listChannelPluginCatalogEntries.mockReturnValue([
1219+
{
1220+
id: "matrix",
1221+
pluginId: "matrix",
1222+
meta: { label: "Matrix" },
1223+
install: {
1224+
clawhubSpec: "clawhub:@openclaw/plugin-matrix@stable",
1225+
defaultChoice: "clawhub",
1226+
},
1227+
},
1228+
]);
1229+
1230+
const { repairMissingConfiguredPluginInstalls } =
1231+
await import("./missing-configured-plugin-install.js");
1232+
const result = await repairMissingConfiguredPluginInstalls({
1233+
cfg: {
1234+
channels: {
1235+
matrix: { enabled: true, homeserver: "https://matrix.example.org" },
1236+
},
1237+
},
1238+
env: {
1239+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
1240+
},
1241+
});
1242+
1243+
expectRecordFields(mockCallArg(mocks.installPluginFromClawHub), {
1244+
spec: "clawhub:@openclaw/plugin-matrix@stable",
1245+
expectedPluginId: "matrix",
1246+
});
1247+
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
1248+
expect(result.changes).toEqual([
1249+
'Installed missing configured plugin "matrix" from clawhub:@openclaw/plugin-matrix@stable.',
1250+
]);
1251+
});
1252+
11121253
it("does not install configured plugins when plugins are globally disabled", async () => {
11131254
mocks.listChannelPluginCatalogEntries.mockReturnValue([
11141255
{

src/commands/doctor/shared/missing-configured-plugin-install.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.
5050
import { resolveUserPath } from "../../../utils.js";
5151
import { VERSION } from "../../../version.js";
5252
import { asObjectRecord } from "./object.js";
53-
import { isUpdatePackageSwapInProgress } from "./update-phase.js";
53+
import {
54+
isLegacyPackageUpdateDoctorPass,
55+
shouldDeferConfiguredPluginInstallRepair,
56+
} from "./update-phase.js";
5457

5558
type DownloadableInstallCandidate = {
5659
pluginId: string;
@@ -763,6 +766,7 @@ async function installCandidate(params: {
763766
records: Record<string, PluginInstallRecord>;
764767
updateChannel?: UpdateChannel;
765768
mode?: "install" | "update";
769+
preferNpm?: boolean;
766770
}): Promise<{
767771
records: Record<string, PluginInstallRecord>;
768772
changes: string[];
@@ -786,7 +790,11 @@ async function installCandidate(params: {
786790
: null;
787791
const clawhubInstallSpec = clawhubSpecs?.installSpec ?? candidate.clawhubSpec;
788792
const npmInstallSpec = npmSpecs?.installSpec ?? candidate.npmSpec;
789-
if (clawhubInstallSpec && candidate.defaultChoice !== "npm") {
793+
const shouldTryClawHub =
794+
clawhubInstallSpec &&
795+
!(params.preferNpm && npmInstallSpec) &&
796+
candidate.defaultChoice !== "npm";
797+
if (shouldTryClawHub) {
790798
const clawhubResult = await installPluginFromClawHub({
791799
spec: clawhubInstallSpec,
792800
extensionsDir,
@@ -1012,6 +1020,7 @@ async function repairMissingPluginInstalls(params: {
10121020
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
10131021
currentVersion: VERSION,
10141022
});
1023+
const preferNpmInstalls = isLegacyPackageUpdateDoctorPass(env);
10151024
let nextRecords = records;
10161025

10171026
for (const [pluginId, record] of Object.entries(records)) {
@@ -1026,7 +1035,7 @@ async function repairMissingPluginInstalls(params: {
10261035
changes.push(`Removed stale managed install record for bundled plugin "${pluginId}".`);
10271036
}
10281037

1029-
if (isUpdatePackageSwapInProgress(env)) {
1038+
if (shouldDeferConfiguredPluginInstallRepair(env)) {
10301039
const updateDeferredPluginIds = collectUpdateDeferredPluginIds({
10311040
cfg: params.cfg,
10321041
env,
@@ -1171,6 +1180,7 @@ async function repairMissingPluginInstalls(params: {
11711180
records: nextRecords,
11721181
updateChannel,
11731182
mode: shouldReplaceBrokenOfficialInstall ? "update" : "install",
1183+
preferNpm: preferNpmInstalls,
11741184
});
11751185
if (shouldReplaceBrokenOfficialInstall) {
11761186
const installedRecord = installed.records[candidate.pluginId];

src/commands/doctor/shared/release-configured-plugin-installs.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,12 +352,18 @@ describe("configured plugin install release step", () => {
352352
},
353353
currentVersion: "2026.5.2-beta.1",
354354
touchedVersion: "2026.5.1",
355-
env: { OPENCLAW_UPDATE_IN_PROGRESS: "1" },
355+
env: {
356+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
357+
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
358+
},
356359
});
357360

358361
const repairCall = readOnlyMissingPluginInstallRepairCall();
359362
expect(repairCall.pluginIds).toEqual(["codex"]);
360-
expect(repairCall.env).toEqual({ OPENCLAW_UPDATE_IN_PROGRESS: "1" });
363+
expect(repairCall.env).toEqual({
364+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
365+
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
366+
});
361367
expect(result).toEqual({
362368
changes: [
363369
'Skipped package-manager repair for configured plugin "codex" during package update; rerun "openclaw doctor --fix" after the update completes.',

src/commands/doctor/shared/release-configured-plugin-installs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search
1212
import { VERSION } from "../../../version.js";
1313
import { repairMissingPluginInstallsForIds } from "./missing-configured-plugin-install.js";
1414
import { asObjectRecord } from "./object.js";
15-
import { isUpdatePackageSwapInProgress } from "./update-phase.js";
15+
import { shouldDeferConfiguredPluginInstallRepair } from "./update-phase.js";
1616

1717
export const CONFIGURED_PLUGIN_INSTALL_RELEASE_VERSION = "2026.5.2-beta.1";
1818

@@ -326,7 +326,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: {
326326
touchedConfig: boolean;
327327
}> {
328328
const env = params.env ?? process.env;
329-
const updateInProgress = isUpdatePackageSwapInProgress(env);
329+
const updateInProgress = shouldDeferConfiguredPluginInstallRepair(env);
330330
const configured = collectReleaseConfiguredPluginIds({ cfg: params.cfg, env });
331331
const shouldRunReleaseStep = shouldRunConfiguredPluginInstallReleaseStep({
332332
currentVersion: params.currentVersion,

0 commit comments

Comments
 (0)