mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 07:38:12 +00:00
fix(update): preserve RPC pre-update config
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}")`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
9
src/infra/update-post-core-context.ts
Normal file
9
src/infra/update-post-core-context.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user