fix(update): prevent stale post-core state reuse (#122309)

* fix(update): isolate post-core handoff env

Co-authored-by: Rohit <rohitjavvadi2@gmail.com>

Punchcard-Session: clear-river-orchard-dd

* chore: leave changelog release-owned
This commit is contained in:
Vincent Koc
2026-08-12 08:10:17 +08:00
committed by GitHub
parent 72d55fc12a
commit ea5f397d37
7 changed files with 205 additions and 22 deletions
+37
View File
@@ -500,6 +500,8 @@ const { defaultRuntime } = await import("../runtime.js");
const postCorePluginConvergence = await import("./update-cli/post-core-plugin-convergence.js");
const { completePostCorePluginUpdate } =
await import("./update-cli/update-command-fresh-doctor.js");
const { continuePostCoreUpdateInFreshProcess } =
await import("./update-cli/update-command-post-core.js");
const runPostCorePluginConvergenceSpy = vi.spyOn(
postCorePluginConvergence,
"runPostCorePluginConvergence",
@@ -1610,6 +1612,41 @@ describe("update-cli", () => {
expectNoSideEffects(updateNpmInstalledPlugins, runDaemonInstall, runDaemonRestart);
});
it("isolates stale handoff values at the post-core CLI spawn boundary", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(FRESH_POST_UPDATE_ENTRYPOINT);
readPackageVersion.mockResolvedValueOnce(null);
await withEnvAsync(
{
OPENCLAW_COMPATIBILITY_HOST_VERSION: "stale-version",
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "beta",
OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: "/tmp/stale-config.json",
OPENCLAW_UNRELATED: "preserved",
},
async () => {
await continuePostCoreUpdateInFreshProcess({
root: "/tmp/openclaw-updated-root",
channel: "stable",
requestedChannel: null,
opts: {},
pluginInstallRecords: {},
updateStartedAtMs: 123,
});
const env = spawnCall()?.[2]?.env;
expect(env?.OPENCLAW_COMPATIBILITY_HOST_VERSION).toBeUndefined();
expect(env?.OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL).toBeUndefined();
expect(env?.OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH).toBeUndefined();
expect(env?.OPENCLAW_UNRELATED).toBe("preserved");
expect(process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION).toBe("stale-version");
expect(process.env.OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL).toBe("beta");
expect(process.env.OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH).toBe(
"/tmp/stale-config.json",
);
},
);
});
it("keeps stopped owned-service config and plugin state through fresh post-core handoff", async () => {
const { root, entrypoints } = setupUpdatedRootRefresh();
mockOwnedGitService();
+8 -11
View File
@@ -40,6 +40,7 @@ import {
type ControlPlaneUpdateSentinelMetaFile,
} from "../../infra/update-control-plane-sentinel.js";
import {
buildPostCoreHandoffEnv,
POST_CORE_UPDATE_ENV,
POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV,
type PreUpdateConfigRestoreInput,
@@ -91,7 +92,6 @@ import {
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
export { POST_CORE_UPDATE_ENV };
export const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
export const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
export const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
export const POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV =
"OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH";
@@ -560,25 +560,22 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
await writePostCoreSourceConfigFile(sourceConfigPath, params.preUpdateConfig);
const jsonMode = params.opts.json === true;
const childStdio = resolvePostCoreUpdateChildStdio(process.platform, jsonMode);
const handoffEnv = buildPostCoreHandoffEnv({
baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
compatHostVersion: postCoreHostVersion,
requestedChannel: params.requestedChannel,
sourceConfigPath: params.preUpdateConfig ? sourceConfigPath : undefined,
});
const child = spawn(params.nodeRunner ?? resolveNodeRunner(), argv, {
stdio: childStdio,
env: {
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
...handoffEnv,
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[POST_CORE_UPDATE_ENV]: "1",
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
...(params.requestedChannel
? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel }
: {}),
[POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath,
[POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV]: installRecordsPath,
[POST_CORE_UPDATE_STARTED_AT_ENV]: String(params.updateStartedAtMs),
...(postCoreHostVersion === null
? {}
: { OPENCLAW_COMPATIBILITY_HOST_VERSION: postCoreHostVersion }),
...(params.preUpdateConfig
? { [POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV]: sourceConfigPath }
: {}),
},
});
// JSON callers own stdout, so child diagnostics must remain off that protocol stream.
+4 -2
View File
@@ -1,6 +1,9 @@
import { readConfigFileSnapshot } from "../../config/config.js";
import { normalizeUpdateChannel } from "../../infra/update-channels.js";
import { POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV } from "../../infra/update-post-core-context.js";
import {
POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV,
POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV,
} from "../../infra/update-post-core-context.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js";
import { readPersistedInstalledPluginIndex } from "../../plugins/installed-plugin-index-store.js";
@@ -21,7 +24,6 @@ import {
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
import {
POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV,
POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV,
POST_CORE_UPDATE_RESULT_PATH_ENV,
POST_CORE_UPDATE_STARTED_AT_ENV,
readPostCorePluginInstallRecordsFile,
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { buildPostCoreHandoffEnv } from "./update-post-core-context.js";
describe("buildPostCoreHandoffEnv", () => {
it("replaces only current-run handoff values without mutating the base env", () => {
const baseEnv: NodeJS.ProcessEnv = {
PATH: "/usr/bin",
OPENCLAW_COMPATIBILITY_HOST_VERSION: "stale-version",
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "beta",
OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: "/tmp/stale-config.json",
OPENCLAW_UNRELATED: "preserved",
};
const absent = buildPostCoreHandoffEnv({ baseEnv });
expect(absent).toEqual({
PATH: "/usr/bin",
OPENCLAW_UNRELATED: "preserved",
});
const fresh = buildPostCoreHandoffEnv({
baseEnv,
compatHostVersion: "2026.8.11",
requestedChannel: "dev",
sourceConfigPath: "/tmp/current-config.json",
});
expect(fresh).toMatchObject({
OPENCLAW_COMPATIBILITY_HOST_VERSION: "2026.8.11",
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: "/tmp/current-config.json",
OPENCLAW_UNRELATED: "preserved",
});
expect(baseEnv).toMatchObject({
OPENCLAW_COMPATIBILITY_HOST_VERSION: "stale-version",
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "beta",
OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: "/tmp/stale-config.json",
OPENCLAW_UNRELATED: "preserved",
});
});
it("clears mixed-case inherited values with Windows environment semantics", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32" });
try {
expect(
buildPostCoreHandoffEnv({
baseEnv: {
OpenClaw_Compatibility_Host_Version: "stale-version",
OpenClaw_Update_Post_Core_Requested_Channel: "beta",
OpenClaw_Update_Post_Core_Source_Config_Path: "C:\\stale-config.json",
OPENCLAW_UNRELATED: "preserved",
},
}),
).toEqual({ OPENCLAW_UNRELATED: "preserved" });
} finally {
Object.defineProperty(process, "platform", platformDescriptor!);
}
});
});
+19
View File
@@ -1,9 +1,28 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { mergeProcessEnv } from "./process-env.js";
import type { UpdateChannel } from "./update-channels.js";
export const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
export const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
export const POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV =
"OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH";
export function buildPostCoreHandoffEnv(params: {
baseEnv: NodeJS.ProcessEnv;
compatHostVersion?: string | null;
requestedChannel?: UpdateChannel | null;
sourceConfigPath?: string;
}): NodeJS.ProcessEnv {
return mergeProcessEnv([
params.baseEnv,
{
OPENCLAW_COMPATIBILITY_HOST_VERSION: params.compatHostVersion || undefined,
[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel || undefined,
[POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV]: params.sourceConfigPath || undefined,
},
]);
}
export type PreUpdateConfigRestoreInput = {
sourceConfig: OpenClawConfig;
authoredConfig: OpenClawConfig;
@@ -1,6 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import {
foldPostCoreFinalizeIntoResult,
runPostCoreFinalizeAfterGatewayUpdate,
@@ -132,6 +135,75 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => {
expect(env.OPENCLAW_GATEWAY_SERVICE_PID).toBeUndefined();
});
it("isolates stale handoff values at the RPC finalizer boundary", async () => {
const spawnFinalize = vi.fn<PostCoreFinalizeSpawner>(async () => ({ code: 0 }));
const baseEnv: NodeJS.ProcessEnv = {
PATH: "/usr/bin",
OPENCLAW_COMPATIBILITY_HOST_VERSION: "stale-version",
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: "/tmp/stale-config.json",
OPENCLAW_UNRELATED: "preserved",
};
await runPostCoreFinalizeAfterGatewayUpdate({
result: gitOkResult({ after: undefined }),
resolveEntrypoint: resolveEntrypointOk,
spawnFinalize,
env: baseEnv,
});
const { env } = expectDefined(
spawnFinalize.mock.calls[0],
"spawnFinalize.mock.calls[0] test invariant",
)[0];
expect(env.OPENCLAW_COMPATIBILITY_HOST_VERSION).toBeUndefined();
expect(env.OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL).toBeUndefined();
expect(env.OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH).toBeUndefined();
expect(env.OPENCLAW_UNRELATED).toBe("preserved");
expect(baseEnv.OPENCLAW_COMPATIBILITY_HOST_VERSION).toBe("stale-version");
expect(baseEnv.OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL).toBe("dev");
expect(baseEnv.OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH).toBe("/tmp/stale-config.json");
});
it("keeps the default process wrapper from restoring ambient handoff values", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-post-core-finalize-"));
const entrypoint = path.join(root, "capture-env.mjs");
const outputPath = path.join(root, "child-env.json");
await fs.writeFile(
entrypoint,
`import fs from "node:fs";
fs.writeFileSync(process.env.OPENCLAW_TEST_OUTPUT_PATH, JSON.stringify({
compatibilityHostVersion: process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION ?? null,
requestedChannel: process.env.OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL ?? null,
sourceConfigPath: process.env.OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH ?? null,
}));`,
"utf8",
);
try {
await withEnvAsync(
{
OPENCLAW_COMPATIBILITY_HOST_VERSION: "stale-version",
OPENCLAW_TEST_OUTPUT_PATH: outputPath,
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "beta",
OPENCLAW_UPDATE_POST_CORE_SOURCE_CONFIG_PATH: "/tmp/stale-config.json",
},
async () => {
const outcome = await runPostCoreFinalizeAfterGatewayUpdate({
result: gitOkResult({ root, after: undefined }),
resolveEntrypoint: async () => entrypoint,
});
expect(outcome).toEqual({ status: "ok", entrypoint });
},
);
await expect(fs.readFile(outputPath, "utf8").then(JSON.parse)).resolves.toEqual({
compatibilityHostVersion: null,
requestedChannel: null,
sourceConfigPath: null,
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("carries the external service-repair policy into the finalizer", async () => {
const spawnFinalize = vi.fn<PostCoreFinalizeSpawner>(async () => ({ code: 0 }));
await runPostCoreFinalizeAfterGatewayUpdate({
+7 -9
View File
@@ -30,7 +30,7 @@ import {
UPDATE_EFFECTIVE_CHANNEL_ENV,
} from "./update-channels.js";
import {
POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV,
buildPostCoreHandoffEnv,
type PreUpdateConfigRestoreInput,
} from "./update-post-core-context.js";
import type { UpdateRunResult } from "./update-runner.js";
@@ -58,17 +58,15 @@ function buildFinalizeEnv(
sourceConfigPath?: string,
serviceRepairPolicy?: "external",
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...baseEnv };
const env = buildPostCoreHandoffEnv({
baseEnv,
compatHostVersion,
sourceConfigPath,
});
delete env.OPENCLAW_SERVICE_MARKER;
delete env.OPENCLAW_SERVICE_KIND;
delete env[GATEWAY_SERVICE_RUNTIME_PID_ENV];
env[UPDATE_EFFECTIVE_CHANNEL_ENV] = effectiveChannel;
if (compatHostVersion) {
env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatHostVersion;
}
if (sourceConfigPath) {
env[POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV] = sourceConfigPath;
}
if (serviceRepairPolicy) {
env.OPENCLAW_SERVICE_REPAIR_POLICY = serviceRepairPolicy;
}
@@ -96,7 +94,7 @@ type PostCoreFinalizeSpawner = (params: {
}) => Promise<FinalizeSpawnResult>;
const defaultFinalizeSpawner: PostCoreFinalizeSpawner = async ({ argv, cwd, timeoutMs, env }) => {
const res = await runCommandWithTimeout(argv, { cwd, timeoutMs, env });
const res = await runCommandWithTimeout(argv, { baseEnv: {}, cwd, timeoutMs, env });
return { code: res.code, ...(res.stderr ? { stderr: res.stderr } : {}) };
};