fix(update): carry effective git channel into post-core finalize

Address ClawSweeper P1 (Carry the effective git channel into finalize):
an unconfigured git/source update runs the core update on the git/dev channel
(runGatewayUpdate: opts.channel ?? "dev"), but the finalizer received no channel
and fell back to the stable package channel, so plugin convergence could resolve
official plugins on the wrong channel.

Mirror the CLI post-core resume's effective/requested channel split: the RPC
finalize path now passes the effective channel (configChannel ?? DEFAULT_GIT_CHANNEL)
to update finalize via OPENCLAW_UPDATE_EFFECTIVE_CHANNEL (convergence-only), never
as --channel. update finalize uses it as a convergence fallback but never persists
update.channel unless the user actually requested one.
This commit is contained in:
masatohoshino
2026-06-18 18:46:18 +08:00
committed by Vincent Koc
parent 07c29c20d6
commit 085ff95fcf
5 changed files with 88 additions and 21 deletions
+34
View File
@@ -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",
+14 -2
View File
@@ -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,
+8
View File
@@ -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";
+11 -6
View File
@@ -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<PostCoreFinalizeSpawner>(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.
+21 -13
View File
@@ -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,