Skip to content

Commit 7c0f546

Browse files
authored
fix(update): isolate plugin sync failures
Disable and skip plugins that fail package-update plugin sync so broken plugin packages do not fail an otherwise successful OpenClaw update.
1 parent fdaa5a0 commit 7c0f546

5 files changed

Lines changed: 212 additions & 74 deletions

File tree

CHANGELOG.md

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

5353
### Fixes
5454

55+
- CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc.
5556
- Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo.
5657
- Browser: enforce strict SSRF current-URL checks before existing-session screenshots, matching existing-session snapshot handling. Thanks @vincentkoc.
5758
- Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc.

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,37 @@ describe("collectMissingPluginInstallPayloads", () => {
222222
await fs.rm(tmpDir, { recursive: true, force: true });
223223
}
224224
});
225+
226+
it("skips disabled tracked records when requested", async () => {
227+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-"));
228+
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing");
229+
try {
230+
await expect(
231+
collectMissingPluginInstallPayloads({
232+
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
233+
skipDisabledPlugins: true,
234+
config: {
235+
plugins: {
236+
entries: {
237+
missing: {
238+
enabled: false,
239+
},
240+
},
241+
},
242+
},
243+
records: {
244+
missing: {
245+
source: "npm",
246+
spec: "@openclaw/missing@beta",
247+
installPath: missingDir,
248+
},
249+
},
250+
}),
251+
).resolves.toEqual([]);
252+
} finally {
253+
await fs.rm(tmpDir, { recursive: true, force: true });
254+
}
255+
});
225256
});
226257

227258
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
resolveGlobalInstallSpec,
5252
} from "../../infra/update-global.js";
5353
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
54+
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
5455
import {
5556
loadInstalledPluginIndexInstallRecords,
5657
withoutPluginInstallRecords,
@@ -184,16 +185,33 @@ async function pathExists(filePath: string): Promise<boolean> {
184185

185186
export async function collectMissingPluginInstallPayloads(params: {
186187
records: Record<string, PluginInstallRecord>;
188+
config?: OpenClawConfig;
189+
skipDisabledPlugins?: boolean;
187190
env?: NodeJS.ProcessEnv;
188191
}): Promise<MissingPluginInstallPayload[]> {
189192
const env = params.env ?? process.env;
193+
const normalizedPluginConfig =
194+
params.skipDisabledPlugins && params.config
195+
? normalizePluginsConfig(params.config.plugins)
196+
: undefined;
190197
const missing: MissingPluginInstallPayload[] = [];
191198
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
192199
left.localeCompare(right),
193200
)) {
194201
if (!isTrackedPackageInstallRecord(record)) {
195202
continue;
196203
}
204+
if (normalizedPluginConfig && params.config) {
205+
const enableState = resolveEffectiveEnableState({
206+
id: pluginId,
207+
origin: "global",
208+
config: normalizedPluginConfig,
209+
rootConfig: params.config,
210+
});
211+
if (!enableState.enabled) {
212+
continue;
213+
}
214+
}
197215
const rawInstallPath = normalizeOptionalString(record.installPath);
198216
if (!rawInstallPath) {
199217
missing.push({ pluginId, reason: "missing-install-path" });
@@ -1091,7 +1109,11 @@ async function updatePluginsAfterCoreUpdate(params: {
10911109
const repairMissingPayloads = async (
10921110
records: Record<string, PluginInstallRecord>,
10931111
): Promise<readonly string[]> => {
1094-
const missing = await collectMissingPluginInstallPayloads({ records });
1112+
const missing = await collectMissingPluginInstallPayloads({
1113+
records,
1114+
config: pluginConfig,
1115+
skipDisabledPlugins: true,
1116+
});
10951117
if (missing.length === 0) {
10961118
return [];
10971119
}
@@ -1110,6 +1132,8 @@ async function updatePluginsAfterCoreUpdate(params: {
11101132
pluginIds: missingIds,
11111133
timeoutMs: params.timeoutMs,
11121134
updateChannel: params.channel,
1135+
skipDisabledPlugins: true,
1136+
disableOnFailure: true,
11131137
logger: pluginLogger,
11141138
onIntegrityDrift: onPluginIntegrityDrift,
11151139
});
@@ -1130,6 +1154,7 @@ async function updatePluginsAfterCoreUpdate(params: {
11301154
updateChannel: params.channel,
11311155
skipIds: new Set([...syncResult.summary.switchedToNpm, ...repairedMissingPayloadIds]),
11321156
skipDisabledPlugins: true,
1157+
disableOnFailure: true,
11331158
logger: pluginLogger,
11341159
onIntegrityDrift: onPluginIntegrityDrift,
11351160
});
@@ -1140,6 +1165,8 @@ async function updatePluginsAfterCoreUpdate(params: {
11401165

11411166
const remainingMissingPayloads = await collectMissingPluginInstallPayloads({
11421167
records: pluginConfig.plugins?.installs ?? {},
1168+
config: pluginConfig,
1169+
skipDisabledPlugins: true,
11431170
});
11441171
pluginUpdateOutcomes.push(
11451172
...remainingMissingPayloads.map(

src/plugins/update.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,61 @@ describe("updateNpmInstalledPlugins", () => {
10321032
]);
10331033
});
10341034

1035+
it("disables enabled tracked plugin update failures when requested", async () => {
1036+
const warn = vi.fn();
1037+
installPluginFromNpmSpecMock.mockResolvedValue({
1038+
ok: false,
1039+
error: "registry timeout",
1040+
});
1041+
const config = {
1042+
plugins: {
1043+
entries: {
1044+
demo: {
1045+
enabled: true,
1046+
config: { preserved: true },
1047+
},
1048+
},
1049+
installs: {
1050+
demo: {
1051+
source: "npm" as const,
1052+
spec: "@acme/demo",
1053+
installPath: "/tmp/demo",
1054+
},
1055+
},
1056+
},
1057+
} satisfies OpenClawConfig;
1058+
1059+
const result = await updateNpmInstalledPlugins({
1060+
config,
1061+
skipDisabledPlugins: true,
1062+
disableOnFailure: true,
1063+
logger: { warn },
1064+
});
1065+
1066+
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
1067+
expect.objectContaining({
1068+
spec: "@acme/demo",
1069+
expectedPluginId: "demo",
1070+
}),
1071+
);
1072+
const message =
1073+
'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: registry timeout';
1074+
expect(warn).toHaveBeenCalledWith(message);
1075+
expect(result.changed).toBe(true);
1076+
expect(result.config.plugins?.entries?.demo).toEqual({
1077+
enabled: false,
1078+
config: { preserved: true },
1079+
});
1080+
expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo);
1081+
expect(result.outcomes).toEqual([
1082+
{
1083+
pluginId: "demo",
1084+
status: "skipped",
1085+
message,
1086+
},
1087+
]);
1088+
});
1089+
10351090
it("aborts exact pinned npm plugin updates on integrity drift by default", async () => {
10361091
const warn = vi.fn();
10371092
installPluginFromNpmSpecMock.mockImplementation(

0 commit comments

Comments
 (0)