fix(update): preserve RPC pre-update config

This commit is contained in:
Vincent Koc
2026-06-18 18:31:53 +08:00
committed by Vincent Koc
parent 4e476d333e
commit c1a414bf28
7 changed files with 227 additions and 15 deletions

View File

@@ -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;

View File

@@ -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<UpdateRunResult["postUpdate"]>["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}")`);

View File

@@ -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<ConfigFileSnapshot>>();
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",

View File

@@ -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<typeof detectRespawnSupervisor>,
@@ -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(

View File

@@ -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;
};

View File

@@ -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<PostCoreFinalizeSpawner>(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<PostCoreFinalizeSpawner>(async () => ({
code: 1,

View File

@@ -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<string | undefined>;
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);
}
}
}