mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user