diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 74a99dc53e30..25be9fd838c1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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(); diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index 02f81f3781ba..1549ce98cef1 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -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. diff --git a/src/cli/update-cli/update-command-resume.ts b/src/cli/update-cli/update-command-resume.ts index f466a36c2524..6c008a013c73 100644 --- a/src/cli/update-cli/update-command-resume.ts +++ b/src/cli/update-cli/update-command-resume.ts @@ -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, diff --git a/src/infra/update-post-core-context.test.ts b/src/infra/update-post-core-context.test.ts new file mode 100644 index 000000000000..1f861c8cd8f9 --- /dev/null +++ b/src/infra/update-post-core-context.test.ts @@ -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!); + } + }); +}); diff --git a/src/infra/update-post-core-context.ts b/src/infra/update-post-core-context.ts index deff96fa64ab..1cb3f4759e13 100644 --- a/src/infra/update-post-core-context.ts +++ b/src/infra/update-post-core-context.ts @@ -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; diff --git a/src/infra/update-post-core-finalize.test.ts b/src/infra/update-post-core-finalize.test.ts index 3c1e7887dc18..59a0f7d4c08c 100644 --- a/src/infra/update-post-core-finalize.test.ts +++ b/src/infra/update-post-core-finalize.test.ts @@ -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(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(async () => ({ code: 0 })); await runPostCoreFinalizeAfterGatewayUpdate({ diff --git a/src/infra/update-post-core-finalize.ts b/src/infra/update-post-core-finalize.ts index b004ef0bcbfc..93f2ac5d99bd 100644 --- a/src/infra/update-post-core-finalize.ts +++ b/src/infra/update-post-core-finalize.ts @@ -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; 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 } : {}) }; };