diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 9001103214a..f33ec52b83f 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -6333,6 +6333,40 @@ describe("update-cli", () => { expect((lastWriteJsonCall() as { channel?: string } | undefined)?.channel).toBe("dev"); }); + it("updateFinalizeCommand converges on the effective channel from env without persisting update.channel", async () => { + const noChannelConfig = {} as OpenClawConfig; + const noChannelSnapshot: ConfigFileSnapshot = { + ...baseSnapshot, + sourceConfig: noChannelConfig, + resolved: noChannelConfig, + runtimeConfig: noChannelConfig, + config: noChannelConfig, + hash: "no-channel", + }; + vi.mocked(readConfigFileSnapshot).mockResolvedValue(noChannelSnapshot); + const priorEffective = process.env.OPENCLAW_UPDATE_EFFECTIVE_CHANNEL; + // Simulate a no-config git/source update whose effective channel is dev. + process.env.OPENCLAW_UPDATE_EFFECTIVE_CHANNEL = "dev"; + try { + await updateFinalizeCommand({ json: true, restart: false }); + } finally { + if (priorEffective === undefined) { + delete process.env.OPENCLAW_UPDATE_EFFECTIVE_CHANNEL; + } else { + process.env.OPENCLAW_UPDATE_EFFECTIVE_CHANNEL = priorEffective; + } + } + // Convergence runs on the effective (git/dev) channel... + expect(syncPluginCall()?.channel).toBe("dev"); + // ...but the effective channel is never persisted to update.channel + // (no requested channel), so a default source update does not mutate config. + expect(syncPluginCall()?.config?.update?.channel).toBeUndefined(); + const persistedDevChannel = vi + .mocked(replaceConfigFile) + .mock.calls.some(([params]) => params?.nextConfig?.update?.channel === "dev"); + expect(persistedDevChannel).toBe(false); + }); + it.each([ { name: "update command invalid timeout", diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 56b5955a5f0..4b2e7e58549 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -64,6 +64,7 @@ import { DEFAULT_GIT_CHANNEL, DEFAULT_PACKAGE_CHANNEL, normalizeUpdateChannel, + UPDATE_EFFECTIVE_CHANNEL_ENV, } from "../../infra/update-channels.js"; import { compareSemverStrings, @@ -2486,7 +2487,14 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis const storedChannel = configSnapshot.valid ? normalizeUpdateChannel(configSnapshot.config.update?.channel) : null; - const channel = requestedChannel ?? storedChannel ?? DEFAULT_PACKAGE_CHANNEL; + // Effective channel the core update actually ran on (e.g. git/dev for an + // unconfigured source update), passed by the caller via env. Used only as a + // convergence fallback; it is never persisted (that stays gated on + // `requestedChannel`), so a default source update does not write update.channel. + const effectiveChannel = normalizeUpdateChannel( + process.env[UPDATE_EFFECTIVE_CHANNEL_ENV]?.trim(), + ); + const channel = requestedChannel ?? storedChannel ?? effectiveChannel ?? DEFAULT_PACKAGE_CHANNEL; if (requestedChannel) { configSnapshot = await persistRequestedUpdateChannel({ configSnapshot, @@ -2514,7 +2522,11 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis ? normalizeUpdateChannel(configSnapshot.config.update?.channel) : null; const postDoctorChannel = - requestedChannel ?? postDoctorStoredChannel ?? storedChannel ?? DEFAULT_PACKAGE_CHANNEL; + requestedChannel ?? + postDoctorStoredChannel ?? + storedChannel ?? + effectiveChannel ?? + DEFAULT_PACKAGE_CHANNEL; const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); return await runPostCorePluginUpdate({ root, diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index ce18d65bb70..083ba6051a3 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -16,6 +16,14 @@ export type UpdateChannelSource = export const DEFAULT_PACKAGE_CHANNEL: UpdateChannel = "stable"; /** Default channel for source installs where branch metadata is unavailable. */ export const DEFAULT_GIT_CHANNEL: UpdateChannel = "dev"; +/** + * Env var carrying the *effective* update channel into `openclaw update finalize` + * (e.g. the git/dev channel a source update actually ran on) without making it a + * *requested* channel. Convergence uses it as a fallback; it is never persisted + * to `update.channel`. Mirrors the CLI post-core resume's effective/requested + * channel split (`OPENCLAW_UPDATE_POST_CORE_CHANNEL` vs `…_REQUESTED_CHANNEL`). + */ +export const UPDATE_EFFECTIVE_CHANNEL_ENV = "OPENCLAW_UPDATE_EFFECTIVE_CHANNEL"; /** Git branch that represents the development update stream. */ export const DEV_BRANCH = "main"; diff --git a/src/infra/update-post-core-finalize.test.ts b/src/infra/update-post-core-finalize.test.ts index 2a8dbb48d7b..32e5b6d2b6d 100644 --- a/src/infra/update-post-core-finalize.test.ts +++ b/src/infra/update-post-core-finalize.test.ts @@ -78,7 +78,9 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => { expect(outcome).toEqual({ status: "ok", entrypoint: ENTRYPOINT }); expect(spawnFinalize).toHaveBeenCalledTimes(1); const call = spawnFinalize.mock.calls[0][0]; - // Reconcile runs through the designed finalizer; never restarts (RPC owns restart). + // Reconcile runs through the designed finalizer; never restarts (RPC owns + // restart). No `--channel` — the channel is passed as effective-only via env + // so the finalizer does not persist it. expect(call.argv).toEqual([ expect.any(String), ENTRYPOINT, @@ -87,11 +89,12 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => { "--json", "--yes", "--no-restart", - "--channel", - "stable", "--timeout", "120", ]); + expect(call.argv).not.toContain("--channel"); + // Configured channel is carried as the effective convergence channel via env. + expect(call.env.OPENCLAW_UPDATE_EFFECTIVE_CHANNEL).toBe("stable"); // Host-compat resolution is pinned to the just-installed core version. expect(call.env.OPENCLAW_COMPATIBILITY_HOST_VERSION).toBe("2026.6.1"); // Outer whole-process timeout is decoupled from the per-step --timeout (120s): @@ -119,7 +122,7 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => { expect(env.OPENCLAW_GATEWAY_SERVICE_PID).toBeUndefined(); }); - it("omits --channel (no config mutation) and --timeout when not provided", async () => { + it("carries effective git/dev channel via env without --channel for a no-config update", async () => { const spawnFinalize = vi.fn(async () => ({ code: 0 })); await runPostCoreFinalizeAfterGatewayUpdate({ result: gitOkResult(), @@ -127,8 +130,10 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => { spawnFinalize, }); const call = spawnFinalize.mock.calls[0][0]; - // No `--channel` unless the caller has a configured channel: `update finalize` - // persists any `--channel`, so a defaulted one would mutate openclaw.json. + // No configured channel → effective channel defaults to the git/dev channel + // the core update ran on, carried via env (convergence-only, not persisted), + // never as `--channel` (which `update finalize` would persist to openclaw.json). + expect(call.env.OPENCLAW_UPDATE_EFFECTIVE_CHANNEL).toBe("dev"); expect(call.argv).not.toContain("--channel"); expect(call.argv).not.toContain("--timeout"); // No per-step timeout requested → outer backstop is the floor. diff --git a/src/infra/update-post-core-finalize.ts b/src/infra/update-post-core-finalize.ts index c662bba4619..81e1a5a5512 100644 --- a/src/infra/update-post-core-finalize.ts +++ b/src/infra/update-post-core-finalize.ts @@ -21,7 +21,11 @@ import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveStableNodePath } from "./stable-node-path.js"; -import type { UpdateChannel } from "./update-channels.js"; +import { + DEFAULT_GIT_CHANNEL, + type UpdateChannel, + UPDATE_EFFECTIVE_CHANNEL_ENV, +} from "./update-channels.js"; import type { UpdateRunResult } from "./update-runner.js"; // Whole-process backstop for the finalizer. `update finalize` runs several timed @@ -36,14 +40,20 @@ const FINALIZE_PROCESS_STEP_BUDGET_MULTIPLIER = 6; // Strip the running gateway's service identity from the finalizer child so it is // not mistaken for the managed service process (matches the CLI post-core spawn). +// Also carry the effective update channel so convergence runs on the channel the +// core update actually used (git/dev for an unconfigured source update) — passed +// as the *effective* channel, never a *requested* one, so `update finalize` does +// not persist `update.channel`. function buildFinalizeEnv( baseEnv: NodeJS.ProcessEnv, + effectiveChannel: UpdateChannel, compatHostVersion?: string, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; 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; } @@ -113,9 +123,10 @@ function isGitUpdateNeedingFinalize( function buildFinalizeArgv(params: { nodePath: string; entrypoint: string; - channel?: UpdateChannel; timeoutMs?: number; }): string[] { + // No `--channel`: the effective channel is passed via env (convergence-only, + // not persisted). `update finalize` would persist any `--channel` it sees. const argv = [ params.nodePath, params.entrypoint, @@ -125,9 +136,6 @@ function buildFinalizeArgv(params: { "--yes", "--no-restart", ]; - if (params.channel) { - argv.push("--channel", params.channel); - } if (typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)) { // `update finalize --timeout` is per-step seconds. argv.push("--timeout", String(Math.max(1, Math.ceil(params.timeoutMs / 1000)))); @@ -158,23 +166,23 @@ export async function runPostCoreFinalizeAfterGatewayUpdate(params: { typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) ? params.timeoutMs : undefined; - // Only forward `--channel` when the caller actually has a configured channel. - // `update finalize` treats any `--channel` as an explicit request and persists - // it to `openclaw.json` (`persistRequestedUpdateChannel`); emitting a defaulted - // channel here would write a channel the user never requested. When omitted, - // the finalizer converges on the stored/default channel — the reconcile still - // resolves a host-compatible version, it just does not mutate config. + // This helper only runs for git/source updates, where `runGatewayUpdate` ran + // the core update on `configChannel ?? DEFAULT_GIT_CHANNEL` (dev). Carry that + // same effective channel into the finalizer so plugin convergence matches the + // core update instead of falling back to the package (stable) channel. It is + // passed via env as the *effective* (not requested) channel, so the finalizer + // does not persist `update.channel` when the user never configured one. + const effectiveChannel: UpdateChannel = params.channel ?? DEFAULT_GIT_CHANNEL; const nodePath = await resolveStableNodePath(process.execPath); const argv = buildFinalizeArgv({ nodePath, entrypoint, - ...(params.channel ? { channel: params.channel } : {}), ...(perStepTimeoutMs === undefined ? {} : { timeoutMs: perStepTimeoutMs }), }); // 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, compatHostVersion); + 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,