Skip to content

Commit c1a414b

Browse files
committed
fix(update): preserve RPC pre-update config
1 parent 4e476d3 commit c1a414b

7 files changed

Lines changed: 227 additions & 15 deletions

File tree

src/cli/update-cli.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6298,6 +6298,56 @@ describe("update-cli", () => {
62986298
expect((lastWriteJsonCall() as { channel?: string } | undefined)?.channel).toBe("beta");
62996299
});
63006300

6301+
it("updateFinalizeCommand restores channels from the RPC pre-update config payload", async () => {
6302+
const tempDir = createCaseDir("openclaw-rpc-finalize");
6303+
const sourceConfigPath = path.join(tempDir, "source-config.json");
6304+
const preUpdateConfig = {
6305+
channels: {
6306+
whatsapp: {
6307+
enabled: true,
6308+
dmPolicy: "pairing",
6309+
},
6310+
},
6311+
} as OpenClawConfig;
6312+
const postDoctorConfig = {
6313+
meta: { lastTouchedVersion: "2026.6.18" },
6314+
} as OpenClawConfig;
6315+
const postDoctorSnapshot: ConfigFileSnapshot = {
6316+
...baseSnapshot,
6317+
sourceConfig: postDoctorConfig,
6318+
resolved: postDoctorConfig,
6319+
runtimeConfig: postDoctorConfig,
6320+
config: postDoctorConfig,
6321+
hash: "post-doctor",
6322+
};
6323+
await fs.mkdir(tempDir, { recursive: true });
6324+
await fs.writeFile(
6325+
sourceConfigPath,
6326+
`${JSON.stringify({
6327+
sourceConfig: preUpdateConfig,
6328+
authoredConfig: preUpdateConfig,
6329+
})}\n`,
6330+
"utf-8",
6331+
);
6332+
vi.mocked(readConfigFileSnapshot).mockResolvedValue(postDoctorSnapshot);
6333+
6334+
await withEnvAsync(
6335+
{
6336+
OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: sourceConfigPath,
6337+
},
6338+
async () => {
6339+
await updateFinalizeCommand({ json: true, restart: false });
6340+
},
6341+
);
6342+
6343+
expect(syncPluginCall()?.config?.channels?.whatsapp).toEqual(
6344+
preUpdateConfig.channels?.whatsapp,
6345+
);
6346+
expect(lastReplaceConfigCall()?.nextConfig?.channels?.whatsapp).toEqual(
6347+
preUpdateConfig.channels?.whatsapp,
6348+
);
6349+
});
6350+
63016351
it("updateFinalizeCommand reapplies requested channel against post-doctor config", async () => {
63026352
const preDoctorConfig = { update: { channel: "stable" } } as OpenClawConfig;
63036353
const postDoctorConfig = { update: { channel: "beta" } } as OpenClawConfig;

src/cli/update-cli/update-command.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ import {
9595
type ResolvedGlobalInstallTarget,
9696
} from "../../infra/update-global.js";
9797
import { cleanupStaleManagedServiceUpdateHandoffs } from "../../infra/update-managed-service-handoff-cleanup.js";
98+
import {
99+
POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV,
100+
type PreUpdateConfigRestoreInput,
101+
} from "../../infra/update-post-core-context.js";
98102
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
99103
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
100104
import {
@@ -167,7 +171,6 @@ const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
167171
const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
168172
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
169173
const POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH";
170-
const POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH";
171174
const POST_CORE_UPDATE_STARTED_AT_ENV = "OPENCLAW_UPDATE_POST_CORE_STARTED_AT_MS";
172175
const POST_CORE_UPDATE_RESULT_POLL_MS = 100;
173176
const PRE_UPDATE_CONFIG_SNAPSHOT_MAX_AGE_MS = 6 * 60 * 60 * 1000;
@@ -222,11 +225,6 @@ type PostCorePluginUpdateResult = NonNullable<
222225
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
223226
>;
224227

225-
type PreUpdateConfigRestoreInput = {
226-
sourceConfig: OpenClawConfig;
227-
authoredConfig: OpenClawConfig;
228-
};
229-
230228
type MissingPluginInstallPayload = {
231229
pluginId: string;
232230
installPath?: string;
@@ -2470,14 +2468,19 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
24702468

24712469
const root = await resolveUpdateRoot();
24722470
let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
2473-
const preFinalizeConfig = configSnapshot.valid
2474-
? {
2475-
sourceConfig: configSnapshot.sourceConfig,
2476-
authoredConfig: isRecord(configSnapshot.parsed)
2477-
? (configSnapshot.parsed as OpenClawConfig)
2478-
: configSnapshot.sourceConfig,
2479-
}
2480-
: undefined;
2471+
const preFinalizeConfig =
2472+
(await readPostCorePreUpdateSourceConfig({
2473+
sourceConfigPath: process.env[POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV],
2474+
currentSnapshot: configSnapshot,
2475+
})) ??
2476+
(configSnapshot.valid
2477+
? {
2478+
sourceConfig: configSnapshot.sourceConfig,
2479+
authoredConfig: isRecord(configSnapshot.parsed)
2480+
? (configSnapshot.parsed as OpenClawConfig)
2481+
: configSnapshot.sourceConfig,
2482+
}
2483+
: undefined);
24812484
const requestedChannel = normalizeUpdateChannel(opts.channel);
24822485
if (opts.channel && !requestedChannel) {
24832486
defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`);

src/gateway/server-methods/update.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Update method tests cover update.run/status, restart sentinel metadata,
22
// managed-service handoff, restart scheduling, and delivery context preservation.
33
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import type { ConfigFileSnapshot, OpenClawConfig } from "../../config/types.openclaw.js";
45
import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js";
56
import type { RespawnSupervisor } from "../../infra/supervisor-markers.js";
67
import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js";
@@ -24,6 +25,7 @@ const isRestartEnabledMock = vi.fn(() => true);
2425
const readPackageVersionMock = vi.fn(async () => "1.0.0");
2526
const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null);
2627
const normalizeUpdateChannelMock = vi.fn((): "stable" | "beta" | "dev" | null => null);
28+
const readConfigFileSnapshotMock = vi.fn<() => Promise<ConfigFileSnapshot>>();
2729
const startManagedServiceUpdateHandoffMock = vi.fn(async () => ({
2830
status: "started" as const,
2931
pid: 12345,
@@ -52,6 +54,7 @@ type UpdateRunPayload = {
5254

5355
vi.mock("../../config/config.js", () => ({
5456
getRuntimeConfig: () => ({ update: {} }),
57+
readConfigFileSnapshot: readConfigFileSnapshotMock,
5558
}));
5659

5760
vi.mock("../../config/commands.flags.js", () => ({
@@ -179,6 +182,21 @@ beforeEach(() => {
179182
readPackageVersionMock.mockResolvedValue("1.0.0");
180183
normalizeUpdateChannelMock.mockReset();
181184
normalizeUpdateChannelMock.mockReturnValue(null);
185+
readConfigFileSnapshotMock.mockReset();
186+
readConfigFileSnapshotMock.mockResolvedValue({
187+
path: "/tmp/openclaw.json",
188+
exists: true,
189+
raw: "{}",
190+
parsed: {},
191+
resolved: {} as OpenClawConfig,
192+
sourceConfig: {} as OpenClawConfig,
193+
valid: true,
194+
config: {} as OpenClawConfig,
195+
runtimeConfig: {} as OpenClawConfig,
196+
issues: [],
197+
warnings: [],
198+
legacyIssues: [],
199+
});
182200
detectRespawnSupervisorMock.mockReset();
183201
detectRespawnSupervisorMock.mockReturnValue(null);
184202
runGatewayUpdateMock.mockClear();
@@ -724,6 +742,46 @@ describe("update.run post-core plugin finalize", () => {
724742
expect(payload?.result?.status).toBe("ok");
725743
});
726744

745+
it("carries the pre-doctor source config into the git finalizer", async () => {
746+
const preUpdateConfig = {
747+
channels: {
748+
whatsapp: {
749+
enabled: true,
750+
},
751+
},
752+
} as OpenClawConfig;
753+
readConfigFileSnapshotMock.mockResolvedValueOnce({
754+
path: "/tmp/openclaw.json",
755+
exists: true,
756+
raw: JSON.stringify(preUpdateConfig),
757+
parsed: preUpdateConfig,
758+
resolved: preUpdateConfig,
759+
sourceConfig: preUpdateConfig,
760+
valid: true,
761+
config: preUpdateConfig,
762+
runtimeConfig: preUpdateConfig,
763+
issues: [],
764+
warnings: [],
765+
legacyIssues: [],
766+
});
767+
runPostCoreFinalizeAfterGatewayUpdateMock.mockResolvedValueOnce({
768+
status: "ok",
769+
entrypoint: "/tmp/openclaw-git/dist/index.mjs",
770+
});
771+
mockGitOkUpdate("/tmp/openclaw-git");
772+
773+
await captureUpdateRunPayload();
774+
775+
const [finalizeParams] = firstMockCall(
776+
runPostCoreFinalizeAfterGatewayUpdateMock,
777+
"post-core finalize",
778+
) as [{ preUpdateConfig?: { sourceConfig?: OpenClawConfig; authoredConfig?: OpenClawConfig } }];
779+
expect(finalizeParams.preUpdateConfig).toEqual({
780+
sourceConfig: preUpdateConfig,
781+
authoredConfig: preUpdateConfig,
782+
});
783+
});
784+
727785
it("blocks the restart when post-core plugin finalize fails", async () => {
728786
runPostCoreFinalizeAfterGatewayUpdateMock.mockResolvedValueOnce({
729787
status: "error",

src/gateway/server-methods/update.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
// sentinels, and hand off managed-service restarts when needed.
33
import { randomUUID } from "node:crypto";
44
import os from "node:os";
5+
import { isRecord } from "@openclaw/normalization-core/record-coerce";
56
import {
67
validateUpdateRunParams,
78
validateUpdateStatusParams,
89
} from "../../../packages/gateway-protocol/src/index.js";
910
import { isRestartEnabled } from "../../config/commands.flags.js";
11+
import { readConfigFileSnapshot } from "../../config/config.js";
1012
import { extractDeliveryInfo } from "../../config/sessions.js";
13+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1114
import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "../../daemon/constants.js";
1215
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
1316
import { readPackageVersion } from "../../infra/package-json.js";
@@ -21,6 +24,7 @@ import {
2124
formatManagedServiceUpdateCommand,
2225
startManagedServiceUpdateHandoff,
2326
} from "../../infra/update-managed-service-handoff.js";
27+
import type { PreUpdateConfigRestoreInput } from "../../infra/update-post-core-context.js";
2428
import {
2529
foldPostCoreFinalizeIntoResult,
2630
runPostCoreFinalizeAfterGatewayUpdate,
@@ -57,6 +61,21 @@ function tryResolveProcessCwd(): string | undefined {
5761
}
5862
}
5963

64+
async function readPreUpdateConfigForPostCoreFinalize(): Promise<
65+
PreUpdateConfigRestoreInput | undefined
66+
> {
67+
const snapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
68+
if (!snapshot.valid) {
69+
return undefined;
70+
}
71+
return {
72+
sourceConfig: snapshot.sourceConfig,
73+
authoredConfig: isRecord(snapshot.parsed)
74+
? (snapshot.parsed as OpenClawConfig)
75+
: snapshot.sourceConfig,
76+
};
77+
}
78+
6079
function resolveManagedServiceHandoffRestartDelayMs(
6180
restartDelayMs: number | undefined,
6281
supervisor: ReturnType<typeof detectRespawnSupervisor>,
@@ -275,6 +294,15 @@ export const updateHandlers: GatewayRequestHandlers = {
275294
};
276295
}
277296
} else {
297+
const preUpdateConfig =
298+
installSurface.kind === "git"
299+
? await readPreUpdateConfigForPostCoreFinalize().catch((err) => {
300+
context?.logGateway?.warn(
301+
`update.run could not capture pre-update config ${formatControlPlaneActor(actor)} error=${formatUpdateRunErrorMessage(err)}`,
302+
);
303+
return undefined;
304+
})
305+
: undefined;
278306
result = await runGatewayUpdate({
279307
timeoutMs,
280308
cwd: root,
@@ -288,6 +316,7 @@ export const updateHandlers: GatewayRequestHandlers = {
288316
result,
289317
channel: configChannel ?? undefined,
290318
...(timeoutMs === undefined ? {} : { timeoutMs }),
319+
...(preUpdateConfig ? { preUpdateConfig } : {}),
291320
});
292321
if (finalizeOutcome.status === "error") {
293322
context?.logGateway?.warn(
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
3+
export const POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV =
4+
"OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH";
5+
6+
export type PreUpdateConfigRestoreInput = {
7+
sourceConfig: OpenClawConfig;
8+
authoredConfig: OpenClawConfig;
9+
};

src/infra/update-post-core-finalize.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs/promises";
12
import { describe, expect, it, vi } from "vitest";
23
import {
34
foldPostCoreFinalizeIntoResult,
@@ -140,6 +141,41 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => {
140141
expect(call.timeoutMs).toBe(30 * 60_000);
141142
});
142143

144+
it("passes and removes the pre-update config payload for channel restoration", async () => {
145+
const preUpdateConfig = {
146+
sourceConfig: {
147+
channels: {
148+
whatsapp: { enabled: true },
149+
},
150+
},
151+
authoredConfig: {
152+
channels: {
153+
whatsapp: { enabled: true },
154+
},
155+
},
156+
};
157+
let sourceConfigPath: string | undefined;
158+
const spawnFinalize = vi.fn<PostCoreFinalizeSpawner>(async ({ env }) => {
159+
sourceConfigPath = env.OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH;
160+
expect(sourceConfigPath).toEqual(expect.any(String));
161+
await expect(fs.readFile(sourceConfigPath!, "utf-8")).resolves.toBe(
162+
`${JSON.stringify(preUpdateConfig)}\n`,
163+
);
164+
return { code: 0 };
165+
});
166+
167+
await expect(
168+
runPostCoreFinalizeAfterGatewayUpdate({
169+
result: gitOkResult(),
170+
preUpdateConfig,
171+
resolveEntrypoint: resolveEntrypointOk,
172+
spawnFinalize,
173+
}),
174+
).resolves.toEqual({ status: "ok", entrypoint: ENTRYPOINT });
175+
176+
await expect(fs.access(sourceConfigPath!)).rejects.toThrow();
177+
});
178+
143179
it("reports error on a non-zero finalize exit", async () => {
144180
const spawnFinalize = vi.fn<PostCoreFinalizeSpawner>(async () => ({
145181
code: 1,

0 commit comments

Comments
 (0)