From c1a414bf286ab9f44d80bbf6809bab03bee1a360 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:31:53 +0800 Subject: [PATCH] fix(update): preserve RPC pre-update config --- src/cli/update-cli.test.ts | 50 ++++++++++++++++++ src/cli/update-cli/update-command.ts | 31 ++++++----- src/gateway/server-methods/update.test.ts | 58 +++++++++++++++++++++ src/gateway/server-methods/update.ts | 29 +++++++++++ src/infra/update-post-core-context.ts | 9 ++++ src/infra/update-post-core-finalize.test.ts | 36 +++++++++++++ src/infra/update-post-core-finalize.ts | 29 ++++++++++- 7 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 src/infra/update-post-core-context.ts diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index f33ec52b83f..96dcbfc972a 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -6298,6 +6298,56 @@ describe("update-cli", () => { expect((lastWriteJsonCall() as { channel?: string } | undefined)?.channel).toBe("beta"); }); + it("updateFinalizeCommand restores channels from the RPC pre-update config payload", async () => { + const tempDir = createCaseDir("openclaw-rpc-finalize"); + const sourceConfigPath = path.join(tempDir, "source-config.json"); + const preUpdateConfig = { + channels: { + whatsapp: { + enabled: true, + dmPolicy: "pairing", + }, + }, + } as OpenClawConfig; + const postDoctorConfig = { + meta: { lastTouchedVersion: "2026.6.18" }, + } as OpenClawConfig; + const postDoctorSnapshot: ConfigFileSnapshot = { + ...baseSnapshot, + sourceConfig: postDoctorConfig, + resolved: postDoctorConfig, + runtimeConfig: postDoctorConfig, + config: postDoctorConfig, + hash: "post-doctor", + }; + await fs.mkdir(tempDir, { recursive: true }); + await fs.writeFile( + sourceConfigPath, + `${JSON.stringify({ + sourceConfig: preUpdateConfig, + authoredConfig: preUpdateConfig, + })}\n`, + "utf-8", + ); + vi.mocked(readConfigFileSnapshot).mockResolvedValue(postDoctorSnapshot); + + await withEnvAsync( + { + OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: sourceConfigPath, + }, + async () => { + await updateFinalizeCommand({ json: true, restart: false }); + }, + ); + + expect(syncPluginCall()?.config?.channels?.whatsapp).toEqual( + preUpdateConfig.channels?.whatsapp, + ); + expect(lastReplaceConfigCall()?.nextConfig?.channels?.whatsapp).toEqual( + preUpdateConfig.channels?.whatsapp, + ); + }); + it("updateFinalizeCommand reapplies requested channel against post-doctor config", async () => { const preDoctorConfig = { update: { channel: "stable" } } as OpenClawConfig; const postDoctorConfig = { update: { channel: "beta" } } as OpenClawConfig; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 4b2e7e58549..c7f7fb99b04 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -95,6 +95,10 @@ import { type ResolvedGlobalInstallTarget, } from "../../infra/update-global.js"; import { cleanupStaleManagedServiceUpdateHandoffs } from "../../infra/update-managed-service-handoff-cleanup.js"; +import { + POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV, + type PreUpdateConfigRestoreInput, +} from "../../infra/update-post-core-context.js"; import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; import { @@ -167,7 +171,6 @@ const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL"; const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; const POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH"; -const POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH"; const POST_CORE_UPDATE_STARTED_AT_ENV = "OPENCLAW_UPDATE_POST_CORE_STARTED_AT_MS"; const POST_CORE_UPDATE_RESULT_POLL_MS = 100; const PRE_UPDATE_CONFIG_SNAPSHOT_MAX_AGE_MS = 6 * 60 * 60 * 1000; @@ -222,11 +225,6 @@ type PostCorePluginUpdateResult = NonNullable< NonNullable["plugins"] >; -type PreUpdateConfigRestoreInput = { - sourceConfig: OpenClawConfig; - authoredConfig: OpenClawConfig; -}; - type MissingPluginInstallPayload = { pluginId: string; installPath?: string; @@ -2470,14 +2468,19 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis const root = await resolveUpdateRoot(); let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); - const preFinalizeConfig = configSnapshot.valid - ? { - sourceConfig: configSnapshot.sourceConfig, - authoredConfig: isRecord(configSnapshot.parsed) - ? (configSnapshot.parsed as OpenClawConfig) - : configSnapshot.sourceConfig, - } - : undefined; + const preFinalizeConfig = + (await readPostCorePreUpdateSourceConfig({ + sourceConfigPath: process.env[POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV], + currentSnapshot: configSnapshot, + })) ?? + (configSnapshot.valid + ? { + sourceConfig: configSnapshot.sourceConfig, + authoredConfig: isRecord(configSnapshot.parsed) + ? (configSnapshot.parsed as OpenClawConfig) + : configSnapshot.sourceConfig, + } + : undefined); const requestedChannel = normalizeUpdateChannel(opts.channel); if (opts.channel && !requestedChannel) { defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`); diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 8f3d4f08b42..518712f581a 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -1,6 +1,7 @@ // Update method tests cover update.run/status, restart sentinel metadata, // managed-service handoff, restart scheduling, and delivery context preservation. import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConfigFileSnapshot, OpenClawConfig } from "../../config/types.openclaw.js"; import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js"; import type { RespawnSupervisor } from "../../infra/supervisor-markers.js"; import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js"; @@ -24,6 +25,7 @@ const isRestartEnabledMock = vi.fn(() => true); const readPackageVersionMock = vi.fn(async () => "1.0.0"); const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null); const normalizeUpdateChannelMock = vi.fn((): "stable" | "beta" | "dev" | null => null); +const readConfigFileSnapshotMock = vi.fn<() => Promise>(); const startManagedServiceUpdateHandoffMock = vi.fn(async () => ({ status: "started" as const, pid: 12345, @@ -52,6 +54,7 @@ type UpdateRunPayload = { vi.mock("../../config/config.js", () => ({ getRuntimeConfig: () => ({ update: {} }), + readConfigFileSnapshot: readConfigFileSnapshotMock, })); vi.mock("../../config/commands.flags.js", () => ({ @@ -179,6 +182,21 @@ beforeEach(() => { readPackageVersionMock.mockResolvedValue("1.0.0"); normalizeUpdateChannelMock.mockReset(); normalizeUpdateChannelMock.mockReturnValue(null); + readConfigFileSnapshotMock.mockReset(); + readConfigFileSnapshotMock.mockResolvedValue({ + path: "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + resolved: {} as OpenClawConfig, + sourceConfig: {} as OpenClawConfig, + valid: true, + config: {} as OpenClawConfig, + runtimeConfig: {} as OpenClawConfig, + issues: [], + warnings: [], + legacyIssues: [], + }); detectRespawnSupervisorMock.mockReset(); detectRespawnSupervisorMock.mockReturnValue(null); runGatewayUpdateMock.mockClear(); @@ -724,6 +742,46 @@ describe("update.run post-core plugin finalize", () => { expect(payload?.result?.status).toBe("ok"); }); + it("carries the pre-doctor source config into the git finalizer", async () => { + const preUpdateConfig = { + channels: { + whatsapp: { + enabled: true, + }, + }, + } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + path: "/tmp/openclaw.json", + exists: true, + raw: JSON.stringify(preUpdateConfig), + parsed: preUpdateConfig, + resolved: preUpdateConfig, + sourceConfig: preUpdateConfig, + valid: true, + config: preUpdateConfig, + runtimeConfig: preUpdateConfig, + issues: [], + warnings: [], + legacyIssues: [], + }); + runPostCoreFinalizeAfterGatewayUpdateMock.mockResolvedValueOnce({ + status: "ok", + entrypoint: "/tmp/openclaw-git/dist/index.mjs", + }); + mockGitOkUpdate("/tmp/openclaw-git"); + + await captureUpdateRunPayload(); + + const [finalizeParams] = firstMockCall( + runPostCoreFinalizeAfterGatewayUpdateMock, + "post-core finalize", + ) as [{ preUpdateConfig?: { sourceConfig?: OpenClawConfig; authoredConfig?: OpenClawConfig } }]; + expect(finalizeParams.preUpdateConfig).toEqual({ + sourceConfig: preUpdateConfig, + authoredConfig: preUpdateConfig, + }); + }); + it("blocks the restart when post-core plugin finalize fails", async () => { runPostCoreFinalizeAfterGatewayUpdateMock.mockResolvedValueOnce({ status: "error", diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index 9b6910407ff..170e8d2de3a 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -2,12 +2,15 @@ // sentinels, and hand off managed-service restarts when needed. import { randomUUID } from "node:crypto"; import os from "node:os"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { validateUpdateRunParams, validateUpdateStatusParams, } from "../../../packages/gateway-protocol/src/index.js"; import { isRestartEnabled } from "../../config/commands.flags.js"; +import { readConfigFileSnapshot } from "../../config/config.js"; import { extractDeliveryInfo } from "../../config/sessions.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "../../daemon/constants.js"; import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; import { readPackageVersion } from "../../infra/package-json.js"; @@ -21,6 +24,7 @@ import { formatManagedServiceUpdateCommand, startManagedServiceUpdateHandoff, } from "../../infra/update-managed-service-handoff.js"; +import type { PreUpdateConfigRestoreInput } from "../../infra/update-post-core-context.js"; import { foldPostCoreFinalizeIntoResult, runPostCoreFinalizeAfterGatewayUpdate, @@ -57,6 +61,21 @@ function tryResolveProcessCwd(): string | undefined { } } +async function readPreUpdateConfigForPostCoreFinalize(): Promise< + PreUpdateConfigRestoreInput | undefined +> { + const snapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); + if (!snapshot.valid) { + return undefined; + } + return { + sourceConfig: snapshot.sourceConfig, + authoredConfig: isRecord(snapshot.parsed) + ? (snapshot.parsed as OpenClawConfig) + : snapshot.sourceConfig, + }; +} + function resolveManagedServiceHandoffRestartDelayMs( restartDelayMs: number | undefined, supervisor: ReturnType, @@ -275,6 +294,15 @@ export const updateHandlers: GatewayRequestHandlers = { }; } } else { + const preUpdateConfig = + installSurface.kind === "git" + ? await readPreUpdateConfigForPostCoreFinalize().catch((err) => { + context?.logGateway?.warn( + `update.run could not capture pre-update config ${formatControlPlaneActor(actor)} error=${formatUpdateRunErrorMessage(err)}`, + ); + return undefined; + }) + : undefined; result = await runGatewayUpdate({ timeoutMs, cwd: root, @@ -288,6 +316,7 @@ export const updateHandlers: GatewayRequestHandlers = { result, channel: configChannel ?? undefined, ...(timeoutMs === undefined ? {} : { timeoutMs }), + ...(preUpdateConfig ? { preUpdateConfig } : {}), }); if (finalizeOutcome.status === "error") { context?.logGateway?.warn( diff --git a/src/infra/update-post-core-context.ts b/src/infra/update-post-core-context.ts new file mode 100644 index 00000000000..3f1fac9d46c --- /dev/null +++ b/src/infra/update-post-core-context.ts @@ -0,0 +1,9 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +export const POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV = + "OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH"; + +export type PreUpdateConfigRestoreInput = { + sourceConfig: OpenClawConfig; + authoredConfig: OpenClawConfig; +}; diff --git a/src/infra/update-post-core-finalize.test.ts b/src/infra/update-post-core-finalize.test.ts index fdecf92bc90..f299964bed3 100644 --- a/src/infra/update-post-core-finalize.test.ts +++ b/src/infra/update-post-core-finalize.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { describe, expect, it, vi } from "vitest"; import { foldPostCoreFinalizeIntoResult, @@ -140,6 +141,41 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => { expect(call.timeoutMs).toBe(30 * 60_000); }); + it("passes and removes the pre-update config payload for channel restoration", async () => { + const preUpdateConfig = { + sourceConfig: { + channels: { + whatsapp: { enabled: true }, + }, + }, + authoredConfig: { + channels: { + whatsapp: { enabled: true }, + }, + }, + }; + let sourceConfigPath: string | undefined; + const spawnFinalize = vi.fn(async ({ env }) => { + sourceConfigPath = env.OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH; + expect(sourceConfigPath).toEqual(expect.any(String)); + await expect(fs.readFile(sourceConfigPath!, "utf-8")).resolves.toBe( + `${JSON.stringify(preUpdateConfig)}\n`, + ); + return { code: 0 }; + }); + + await expect( + runPostCoreFinalizeAfterGatewayUpdate({ + result: gitOkResult(), + preUpdateConfig, + resolveEntrypoint: resolveEntrypointOk, + spawnFinalize, + }), + ).resolves.toEqual({ status: "ok", entrypoint: ENTRYPOINT }); + + await expect(fs.access(sourceConfigPath!)).rejects.toThrow(); + }); + it("reports error on a non-zero finalize exit", async () => { const spawnFinalize = vi.fn(async () => ({ code: 1, diff --git a/src/infra/update-post-core-finalize.ts b/src/infra/update-post-core-finalize.ts index 0c1045953f7..6c8fa24bf38 100644 --- a/src/infra/update-post-core-finalize.ts +++ b/src/infra/update-post-core-finalize.ts @@ -16,6 +16,8 @@ // `updateNpmInstalledPlugins({ syncOfficialPluginInstalls: true, disableOnFailure: true })` // and `runPostCorePluginConvergence`). Finalization never restarts, so the RPC // handler keeps ownership of the gateway restart. +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js"; @@ -27,6 +29,10 @@ import { type UpdateChannel, UPDATE_EFFECTIVE_CHANNEL_ENV, } from "./update-channels.js"; +import { + POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV, + type PreUpdateConfigRestoreInput, +} from "./update-post-core-context.js"; import type { UpdateRunResult } from "./update-runner.js"; // Whole-process backstop for the finalizer. `update finalize` runs several timed @@ -49,6 +55,7 @@ function buildFinalizeEnv( baseEnv: NodeJS.ProcessEnv, effectiveChannel: UpdateChannel, compatHostVersion?: string, + sourceConfigPath?: string, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; delete env.OPENCLAW_SERVICE_MARKER; @@ -58,6 +65,9 @@ function buildFinalizeEnv( if (compatHostVersion) { env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatHostVersion; } + if (sourceConfigPath) { + env[POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV] = sourceConfigPath; + } return env; } @@ -130,6 +140,7 @@ export async function runPostCoreFinalizeAfterGatewayUpdate(params: { result: UpdateRunResult; channel?: UpdateChannel; timeoutMs?: number; + preUpdateConfig?: PreUpdateConfigRestoreInput; resolveEntrypoint?: (root: string) => Promise; spawnFinalize?: PostCoreFinalizeSpawner; env?: NodeJS.ProcessEnv; @@ -165,14 +176,26 @@ export async function runPostCoreFinalizeAfterGatewayUpdate(params: { // Pin the finalizer's host-compat resolution to the just-installed core // version so plugins reconcile against the new core, not the running process. const compatHostVersion = result.after?.version ?? undefined; - const env = buildFinalizeEnv(params.env ?? process.env, effectiveChannel, compatHostVersion); // Outer whole-process backstop, decoupled from the per-step `--timeout` above. const processTimeoutMs = Math.max( FINALIZE_PROCESS_TIMEOUT_FLOOR_MS, (perStepTimeoutMs ?? 0) * FINALIZE_PROCESS_STEP_BUDGET_MULTIPLIER, ); + let sourceConfigDir: string | undefined; try { + let sourceConfigPath: string | undefined; + if (params.preUpdateConfig) { + sourceConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")); + sourceConfigPath = path.join(sourceConfigDir, "source-config.json"); + await fs.writeFile(sourceConfigPath, `${JSON.stringify(params.preUpdateConfig)}\n`, "utf-8"); + } + const env = buildFinalizeEnv( + params.env ?? process.env, + effectiveChannel, + compatHostVersion, + sourceConfigPath, + ); const spawnResult = await spawnFinalize({ argv, cwd: path.dirname(entrypoint), @@ -196,6 +219,10 @@ export async function runPostCoreFinalizeAfterGatewayUpdate(params: { entrypoint, message: err instanceof Error ? err.message : String(err), }; + } finally { + if (sourceConfigDir) { + await fs.rm(sourceConfigDir, { recursive: true, force: true }).catch(() => undefined); + } } }