fix(update): migrate plugin config before final validation (#122199)

Stage transitional plugin install-record writes without plugin-schema validation, then require fresh doctor and strict validation for every aggregate plugin change.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vincent Koc
2026-08-12 03:16:22 +08:00
committed by GitHub
parent 746a188c90
commit e8885eefcd
4 changed files with 77 additions and 7 deletions
+71
View File
@@ -2410,6 +2410,32 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
it("runs the final fresh doctor for convergence-only current-process changes", async () => {
mockGitUpdateAfterMutation();
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(FRESH_POST_UPDATE_ENTRYPOINT);
runPostCorePluginConvergenceSpy.mockResolvedValueOnce({
changes: ["Repaired configured plugin install records."],
warnings: [],
errored: false,
smokeFailures: [],
installRecords: {},
});
await updateCommand({ yes: true, restart: false });
expect(spawn).not.toHaveBeenCalled();
const doctorCall = vi.mocked(runExec).mock.calls.find(([, args]) => args[1] === "doctor");
expect(doctorCall?.[2]).toMatchObject({
env: { OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1" },
});
const strictValidationCall = vi
.mocked(runExec)
.mock.calls.find(([, args]) => args[1] === "config" && args[2] === "validate");
expect(strictValidationCall?.[2]).toMatchObject({
env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" },
});
});
it("runs the fresh plugin doctor with the selected Node runner", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
@@ -2547,6 +2573,51 @@ describe("update-cli", () => {
expect(spawn).not.toHaveBeenCalled();
});
it("stages plugin-changing post-core config before updated plugin migrations run", async () => {
syncPluginsForUpdateChannel.mockImplementationOnce(async ({ config }) =>
pluginSyncResult(config, true),
);
await runPostCoreCommand({ restart: false });
expect(lastReplaceConfigCall()).toMatchObject({
writeOptions: { skipPluginValidation: true },
});
});
it("runs the final fresh doctor for convergence-only post-core changes", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(FRESH_POST_UPDATE_ENTRYPOINT);
runPostCorePluginConvergenceSpy.mockResolvedValueOnce({
changes: ["Repaired configured plugin install records."],
warnings: [],
errored: false,
smokeFailures: [],
installRecords: {},
});
await runPostCoreCommand({ restart: false });
expect(syncPluginCall()?.config).toBeDefined();
expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1);
const doctorCalls = vi.mocked(runExec).mock.calls.filter(([, args]) => args[1] === "doctor");
expect(doctorCalls).toHaveLength(2);
expect(doctorCalls[1]?.[2]).toMatchObject({
env: { OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1" },
});
const strictValidationCall = vi
.mocked(runExec)
.mock.calls.find(
([, args]) =>
args[0] === FRESH_POST_UPDATE_ENTRYPOINT &&
args[1] === "config" &&
args[2] === "validate" &&
args[3] === "--json",
);
expect(strictValidationCall?.[2]).toMatchObject({
env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" },
});
});
it("keeps fresh doctor output off stdout during json post-core resume", async () => {
vi.mocked(runExec).mockResolvedValueOnce({
stdout: "doctor ui output",
@@ -476,11 +476,14 @@ export async function updatePluginsAfterCoreUpdate(params: {
channels: structuredClone(params.restoredAuthoredChannels) as OpenClawConfig["channels"],
};
}
// Installed plugin metadata can own migrations that this process has not loaded yet.
// Finalization runs fresh doctor plus strict validation before the update can complete.
await commitPluginInstallRecordsWithConfig({
previousInstallRecords: pluginInstallRecords,
nextInstallRecords,
nextConfig,
baseHash: params.configSnapshot.hash,
writeOptions: { skipPluginValidation: true },
});
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
@@ -288,12 +288,9 @@ export async function finishUpdate(params: {
const completedPluginUpdate = await completePostCorePluginUpdate({
root: postUpdateRoot,
pluginUpdate: initialPluginUpdate,
// A plugin-only update can replace its migration owner without replacing core.
// Downgrades and resume fallbacks can also leave an updated core on disk in this process.
// Aggregate plugin changes and core install changes independently require fresh doctor.
freshDoctorRequired:
didCoreUpdateChangeInstall(params.result) ||
initialPluginUpdate.sync.changed ||
initialPluginUpdate.npm.changed,
didCoreUpdateChangeInstall(params.result) || initialPluginUpdate.changed,
yes: params.opts.yes === true,
json: params.opts.json === true,
timeoutMs: params.updateStepTimeoutMs,
+1 -2
View File
@@ -127,8 +127,7 @@ async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams):
const { pluginUpdate } = await completePostCorePluginUpdate({
root: params.root,
pluginUpdate: initialPluginUpdate,
// Only package/channel sync can replace the migration owner loaded by this process.
freshDoctorRequired: initialPluginUpdate.sync.changed || initialPluginUpdate.npm.changed,
freshDoctorRequired: initialPluginUpdate.changed,
yes: params.opts.yes === true,
json: params.opts.json === true,
timeoutMs: params.timeoutMs,