mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } : {}) };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user