fix(update): mandatory post-core plugin convergence before gateway restart

Summary:
- validate active plugin payloads, including openclaw.extensions entry files, after core package updates
- treat corrupt active install records without installPath as convergence failures
- prevent managed gateway recovery restart when post-core plugin convergence fails

Verification:
- CI=true pnpm test src/cli/update-cli/plugin-payload-validation.test.ts src/cli/update-cli/post-core-plugin-convergence.test.ts src/cli/update-cli.test.ts src/commands/doctor/shared/missing-configured-plugin-install.test.ts src/commands/doctor/shared/update-phase.test.ts
- CI=true pnpm check:changed
- PR checks green for 2afa84dffe
This commit is contained in:
B.K.
2026-05-12 10:02:10 +03:00
committed by GitHub
parent e7ba2f9b0d
commit 109493bcdd
15 changed files with 1304 additions and 73 deletions

View File

@@ -7,6 +7,7 @@ import {
resolveGatewayInstallEntrypoint,
} from "../../daemon/gateway-entrypoint.js";
import {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
@@ -15,6 +16,7 @@ import {
shouldPrepareUpdatedInstallRestart,
resolveUpdatedGatewayRestartPort,
shouldUseLegacyProcessRestartAfterUpdate,
updatePluginsAfterCoreUpdate,
} from "./update-command.js";
describe("resolveGatewayInstallEntrypointCandidates", () => {
@@ -558,3 +560,62 @@ describe("resolvePostCoreUpdateChildStdio", () => {
expect(resolvePostCoreUpdateChildStdio("darwin")).toBe("inherit");
});
});
describe("updatePluginsAfterCoreUpdate (invalid config end-to-end)", () => {
it("returns status:error (not skipped) when configSnapshot is invalid, so the pre-restart gate fires", async () => {
// The pre-restart gate in `updateCommand` is literally
// if (postCorePluginUpdate?.status === "error") { exit(1) }
// so asserting that this function returns status:"error" on invalid
// config is sufficient to prove the gate fires end-to-end. We pass
// `json: true` to suppress logging side-effects without mocking.
const result = await updatePluginsAfterCoreUpdate({
root: "/tmp/openclaw-test",
channel: "stable",
configSnapshot: {
valid: false,
issues: [],
legacyIssues: [],
} as unknown as Awaited<
ReturnType<typeof import("../../config/io.js").readConfigFileSnapshot>
>,
opts: { json: true } as never,
timeoutMs: 1000,
});
expect(result.status).toBe("error");
expect(result.reason).toBe("invalid-config");
expect(result.changed).toBe(false);
expect(result.warnings).toEqual([
expect.objectContaining({
reason: "invalid-config",
guidance: expect.arrayContaining([expect.stringContaining("openclaw doctor")]),
}),
]);
});
});
describe("buildInvalidConfigPostCoreUpdateResult", () => {
it("returns status:error so the existing pre-restart gate exits 1 instead of restarting on invalid config", () => {
const built = buildInvalidConfigPostCoreUpdateResult();
expect(built.result.status).toBe("error");
expect(built.result.reason).toBe("invalid-config");
expect(built.result.changed).toBe(false);
});
it("surfaces actionable repair guidance in both the structural warnings and the message string", () => {
const built = buildInvalidConfigPostCoreUpdateResult();
expect(built.guidance).toEqual(
expect.arrayContaining([
expect.stringContaining("openclaw doctor"),
expect.stringContaining("openclaw update"),
]),
);
expect(built.result.warnings).toEqual([
{
reason: "invalid-config",
message: built.message,
guidance: built.guidance,
},
]);
expect(built.message).toMatch(/refusing to restart/);
});
});