fix(update): preserve upstream after pinned dev updates (#121328)

This commit is contained in:
Peter Steinberger
2026-08-09 18:50:12 -07:00
committed by GitHub
parent 2f97e8c9eb
commit f4e62523af
20 changed files with 938 additions and 154 deletions
+82
View File
@@ -14,6 +14,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js";
import type { ClawHubRiskAcknowledgementRequest } from "../infra/clawhub-install-trust.js";
import { isBetaTag } from "../infra/update-channels.js";
import { applyDevUpdateTargetEnv } from "../infra/update-dev-target.js";
import {
createDeferredConfiguredPluginRepairDoctorResult,
UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
@@ -7449,6 +7450,87 @@ describe("update-cli", () => {
});
});
it.each([
{
name: "ref-only as detached",
env: { OPENCLAW_UPDATE_DEV_TARGET_REF: "frozen-sha" },
expected: { mode: "detached", ref: "frozen-sha" },
},
{
name: "versioned tracked target",
env: applyDevUpdateTargetEnv(
{},
{ mode: "tracked", upstreamRef: "origin/main", upstreamSha: "frozen-sha" },
),
expected: { mode: "tracked", upstreamRef: "origin/main", upstreamSha: "frozen-sha" },
},
])("maps the internal dev target environment $name", async ({ env, expected }) => {
await withEnvAsync(env, async () => {
await updateCommand({ channel: "dev", yes: true, restart: false });
});
expect(vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ devTarget: expected }),
);
});
it.each([
["malformed", "openclaw-dev-target:v1:not+base64url"],
["unknown version", "openclaw-dev-target:v2:hostile-ref"],
["unknown namespace", "other-dev-target:v1:hostile-ref"],
])("rejects a %s tracked dev target before update side effects", async (_name, value) => {
await withEnvAsync({ OPENCLAW_UPDATE_DEV_TARGET_REF: value }, async () => {
await updateCommand({ channel: "dev", yes: true, restart: false });
});
expect(defaultRuntime.error).toHaveBeenCalledWith(
"Invalid internal OPENCLAW_UPDATE_DEV_TARGET_REF contract; expected a plain Git ref or a supported tracked-target encoding.",
);
expect(defaultRuntime.error).toHaveBeenCalledTimes(1);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expectNoSideEffects(
cleanupStaleManagedServiceUpdateHandoffs,
runGatewayUpdate,
launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob,
);
});
it("rejects a malformed inferred dev target before running the update", async () => {
await withEnvAsync(
{ OPENCLAW_UPDATE_DEV_TARGET_REF: "openclaw-dev-target:v1:not+base64url" },
async () => {
await updateCommand({ yes: true, restart: false });
},
);
expect(defaultRuntime.error).toHaveBeenCalledWith(
"Invalid internal OPENCLAW_UPDATE_DEV_TARGET_REF contract; expected a plain Git ref or a supported tracked-target encoding.",
);
expect(defaultRuntime.error).toHaveBeenCalledTimes(1);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob).not.toHaveBeenCalled();
});
it("ignores a malformed dev target for a stable package update", async () => {
mockPackageInstallStatus(createCaseDir("openclaw-stable-update"));
mockCurrentProcessFreshDoctor();
await withEnvAsync(
{ OPENCLAW_UPDATE_DEV_TARGET_REF: "openclaw-dev-target:v1:not+base64url" },
async () => {
await updateCommand({ channel: "stable", yes: true, restart: false });
},
);
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
expect.stringContaining("OPENCLAW_UPDATE_DEV_TARGET_REF"),
);
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
expect(packageInstallCommandCall()).toBeDefined();
expect(runGatewayUpdate).not.toHaveBeenCalled();
});
it("uses ~/openclaw as the default dev checkout directory", async () => {
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue("/tmp/oc-home");
try {
@@ -1,3 +1,4 @@
import type { DevUpdateTarget } from "../../infra/update-dev-target.js";
import type { ResolvedGlobalInstallTarget } from "../../infra/update-global.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
@@ -53,7 +54,7 @@ export async function executeMutableUpdate(params: {
showProgress: boolean;
opts: UpdateCommandOptions;
shouldRestart: boolean;
devTargetRef?: string;
devTarget?: DevUpdateTarget;
packageInstallSpec: string | null;
packageInstallEnv?: NodeJS.ProcessEnv;
packageInstallTarget?: ResolvedGlobalInstallTarget;
@@ -223,7 +224,7 @@ export async function executeMutableUpdate(params: {
showProgress: params.showProgress,
opts: params.opts,
stop: params.stop,
devTargetRef: params.devTargetRef,
devTarget: params.devTarget,
beforeGitMutation:
params.updateInstallKind === "git"
? createBeforeGitMutation({
+3 -2
View File
@@ -1,4 +1,5 @@
import type { UpdateChannel } from "../../infra/update-channels.js";
import type { DevUpdateTarget } from "../../infra/update-dev-target.js";
import {
createGlobalInstallEnv,
globalInstallArgs,
@@ -127,7 +128,7 @@ export async function runGitUpdate(params: {
showProgress: boolean;
opts: UpdateCommandOptions;
stop: () => void;
devTargetRef?: string;
devTarget?: DevUpdateTarget;
beforeGitMutation?: BeforeGitMutation;
allowGatewayServiceRepair: boolean;
allowGatewayActivation: boolean;
@@ -167,7 +168,7 @@ export async function runGitUpdate(params: {
progress: params.progress,
channel: params.channel,
tag: params.tag,
devTargetRef: params.devTargetRef,
devTarget: params.devTarget,
deferConfiguredPluginInstallRepair: true,
allowGatewayServiceRepair: params.allowGatewayServiceRepair,
allowGatewayActivation: params.allowGatewayActivation,
+33 -3
View File
@@ -27,6 +27,11 @@ import {
resolveNpmChannelTag,
} from "../../infra/update-check.js";
import { readControlPlaneUpdateSentinelMeta } from "../../infra/update-control-plane-sentinel.js";
import {
parseDevUpdateTargetEnv,
type DevUpdateTarget,
UPDATE_DEV_TARGET_REF_ENV,
} from "../../infra/update-dev-target.js";
import {
canResolveRegistryVersionForPackageTarget,
createGlobalInstallEnv,
@@ -85,6 +90,18 @@ export { updateFinalizeCommand } from "./update-command-post-core.js";
const CLI_NAME = resolveCliName();
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
function readDevUpdateTargetOrExit(): { ok: true; target?: DevUpdateTarget } | { ok: false } {
const parsed = parseDevUpdateTargetEnv(process.env);
if (parsed.status === "invalid") {
defaultRuntime.error(
`Invalid internal ${UPDATE_DEV_TARGET_REF_ENV} contract; expected a plain Git ref or a supported tracked-target encoding.`,
);
defaultRuntime.exit(1);
return { ok: false };
}
return parsed.status === "valid" ? { ok: true, target: parsed.target } : { ok: true };
}
async function withUpdateInProgressEnv<T>(run: () => Promise<T>): Promise<T> {
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
@@ -134,6 +151,14 @@ async function updateCommandInternal(
defaultRuntime.exit(1);
return;
}
let devTarget: DevUpdateTarget | undefined;
if (requestedChannel === "dev") {
const resolvedDevTarget = readDevUpdateTargetOrExit();
if (!resolvedDevTarget.ok) {
return;
}
devTarget = resolvedDevTarget.target;
}
if (!postCoreUpdateResume && opts.dryRun !== true && isGatewayExternallySupervised()) {
defaultRuntime.error(formatExternalSupervisorUpdateRequired());
@@ -246,8 +271,13 @@ async function updateCommandInternal(
currentVersion: VERSION,
installKind: updateInstallKind,
}).channel);
const devTargetRef =
channel === "dev" ? process.env.OPENCLAW_UPDATE_DEV_TARGET_REF?.trim() || undefined : undefined;
if (channel === "dev" && requestedChannel !== "dev") {
const resolvedDevTarget = readDevUpdateTargetOrExit();
if (!resolvedDevTarget.ok) {
return;
}
devTarget = resolvedDevTarget.target;
}
const explicitTag = normalizeTag(opts.tag);
if (channel === "extended-stable" && explicitTag) {
@@ -579,7 +609,7 @@ async function updateCommandInternal(
showProgress,
opts,
shouldRestart,
devTargetRef,
devTarget,
packageInstallSpec,
packageInstallEnv,
packageInstallTarget,
@@ -38,7 +38,9 @@ type UpdateInstallSurface = Awaited<
>;
const resolveUpdateInstallSurfaceMock = vi.fn<() => Promise<UpdateInstallSurface>>();
const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>();
const startManagedServiceUpdateHandoffMock = vi.fn(async () => ({
const startManagedServiceUpdateHandoffMock = vi.fn<
typeof import("../../infra/update-managed-service-handoff.js").startManagedServiceUpdateHandoff
>(async () => ({
status: "started" as const,
pid: 12345,
command: "openclaw update --yes --timeout 1800",
@@ -323,7 +325,11 @@ describe("update.run campaign ownership", () => {
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "dev",
devTargetRef: "frozen-upstream-sha",
devTarget: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "frozen-upstream-sha",
},
}),
);
});
@@ -336,9 +342,11 @@ describe("update.run campaign ownership", () => {
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENCLAW_UPDATE_DEV_TARGET_REF: "frozen-upstream-sha",
}),
devTarget: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "frozen-upstream-sha",
},
}),
);
});
@@ -350,7 +358,7 @@ describe("update.run campaign ownership", () => {
await invokeUpdateRun();
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
expect.not.objectContaining({ devTargetRef: expect.anything() }),
expect.not.objectContaining({ devTarget: expect.anything() }),
);
});
@@ -385,7 +393,7 @@ describe("update.run campaign ownership", () => {
expect(getCampaignStateMock).not.toHaveBeenCalled();
expect(clearCampaignMock).not.toHaveBeenCalled();
expect(runGatewayUpdateMock).toHaveBeenCalledWith(
expect.not.objectContaining({ devTargetRef: expect.anything() }),
expect.not.objectContaining({ devTarget: expect.anything() }),
);
});
+5 -11
View File
@@ -34,6 +34,7 @@ import {
} from "../../infra/update-channels.js";
import { checkUpdateStatus } from "../../infra/update-check.js";
import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "../../infra/update-control-plane-sentinel.js";
import { devUpdateTargetFromGitCampaign } from "../../infra/update-dev-target.js";
import { resolveUpdateInstallRoot } from "../../infra/update-install-root.js";
import {
buildManagedServiceHandoffUnavailableMessage,
@@ -254,9 +255,9 @@ export const updateHandlers: GatewayRequestHandlers = {
}
const adoptedCampaign = gatewayUpdateCampaign.adopt();
const adoptedCampaignId = adoptedCampaign?.campaignId;
const adoptedDevTargetRef =
const adoptedDevTarget =
adoptedCampaign?.target.kind === "git"
? adoptedCampaign.target.upstreamSha.trim() || undefined
? devUpdateTargetFromGitCampaign(adoptedCampaign.target)
: undefined;
const adoptedPackageTargetVersion =
adoptedCampaign?.target.kind === "package"
@@ -409,14 +410,7 @@ export const updateHandlers: GatewayRequestHandlers = {
restartDrainTimeoutMs: resolveGatewayRestartDeferralTimeoutMs(),
...(handoffChannel ? { channel: handoffChannel } : {}),
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
...(adoptedDevTargetRef
? {
env: {
...process.env,
OPENCLAW_UPDATE_DEV_TARGET_REF: adoptedDevTargetRef,
},
}
: {}),
...(adoptedDevTarget ? { devTarget: adoptedDevTarget } : {}),
restartDelayMs: managedRestartDelayMs,
meta: sentinelMeta,
handoffId,
@@ -529,7 +523,7 @@ export const updateHandlers: GatewayRequestHandlers = {
? effectiveChannel
: (configChannel ?? undefined),
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
...(adoptedDevTargetRef ? { devTargetRef: adoptedDevTargetRef } : {}),
...(adoptedDevTarget ? { devTarget: adoptedDevTarget } : {}),
allowGatewayServiceRepair: false,
allowGatewayActivation: false,
});
+14 -14
View File
@@ -52,7 +52,7 @@ import {
hasRestartSentinel,
markUpdateRestartSentinelFailure,
readRestartSentinel,
readUpdateInstallReceipt,
readSuccessfulGitUpdateReceipt,
summarizeRestartSentinel,
trimLogTail,
writeRestartSentinel,
@@ -557,7 +557,11 @@ describe("restart sentinel", () => {
mode: "git",
root: installAlias,
before: { sha: "aaaaaaaa" },
after: { sha: "bbbbbbbb", version: "expected-version" },
after: {
sha: " bbbbbbbb ",
upstreamRef: " origin/main ",
version: "expected-version",
},
},
});
@@ -569,15 +573,11 @@ describe("restart sentinel", () => {
);
await clearRestartSentinel();
await expect(readUpdateInstallReceipt()).resolves.toMatchObject({
kind: "update",
status: "ok",
ts,
stats: {
mode: "git",
root: await fs.realpath(installRoot),
after: { sha: "bbbbbbbb", version: "actual-version" },
},
await expect(readSuccessfulGitUpdateReceipt()).resolves.toEqual({
root: await fs.realpath(installRoot),
sha: "bbbbbbbb",
upstreamRef: "origin/main",
installedAtMs: ts,
});
});
});
@@ -604,7 +604,7 @@ describe("restart sentinel", () => {
process.cwd(),
);
await expect(readUpdateInstallReceipt()).resolves.toBeNull();
await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull();
});
});
@@ -634,7 +634,7 @@ describe("restart sentinel", () => {
stats: { reason: "restart-revision-mismatch" },
},
});
await expect(readUpdateInstallReceipt()).resolves.toBeNull();
await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull();
});
});
@@ -670,7 +670,7 @@ describe("restart sentinel", () => {
stats: { reason: "restart-root-mismatch" },
},
});
await expect(readUpdateInstallReceipt()).resolves.toBeNull();
await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull();
});
});
});
+42 -1
View File
@@ -29,6 +29,13 @@ export type {
RestartSentinelPayload,
} from "./restart-sentinel-store.js";
export type SuccessfulGitUpdateReceipt = {
root: string;
sha: string;
upstreamRef?: string;
installedAtMs: number;
};
const sentinelLog = createSubsystemLogger("restart-sentinel");
export function formatDoctorNonInteractiveHint(
@@ -245,7 +252,7 @@ export async function readRestartSentinel(
}
}
export async function readUpdateInstallReceipt(
async function readUpdateInstallReceiptPayload(
env: NodeJS.ProcessEnv = process.env,
): Promise<RestartSentinelPayload | null> {
try {
@@ -257,6 +264,40 @@ export async function readUpdateInstallReceipt(
}
}
function normalizeSuccessfulGitUpdateReceipt(
payload: RestartSentinelPayload | null,
): SuccessfulGitUpdateReceipt | null {
if (
payload?.kind !== "update" ||
payload.status !== "ok" ||
payload.stats?.mode !== "git" ||
!isPlainRecord(payload.stats.after)
) {
return null;
}
const root = typeof payload.stats.root === "string" ? payload.stats.root.trim() : "";
const sha = typeof payload.stats.after.sha === "string" ? payload.stats.after.sha.trim() : "";
if (!root || !sha) {
return null;
}
const upstreamRef =
typeof payload.stats.after.upstreamRef === "string"
? payload.stats.after.upstreamRef.trim()
: "";
return {
root,
sha,
...(upstreamRef ? { upstreamRef } : {}),
installedAtMs: payload.ts,
};
}
export async function readSuccessfulGitUpdateReceipt(
env: NodeJS.ProcessEnv = process.env,
): Promise<SuccessfulGitUpdateReceipt | null> {
return normalizeSuccessfulGitUpdateReceipt(await readUpdateInstallReceiptPayload(env));
}
export async function hasRestartSentinel(env: NodeJS.ProcessEnv = process.env): Promise<boolean> {
try {
const database = openOpenClawStateDatabase({ env });
+67
View File
@@ -627,6 +627,73 @@ describe("formatGitInstallLabel", () => {
});
describe("checkUpdateStatus", () => {
it("uses a matching receipt upstream only for the detached installed revision", async () => {
await withTempDir({ prefix: "openclaw-update-check-receipt-fallback-" }, async (base) => {
const sourceRoot = path.join(base, "source");
const localRoot = path.join(base, "local");
await initGitRepo(sourceRoot);
await fs.writeFile(
path.join(sourceRoot, "package.json"),
JSON.stringify({ name: "openclaw", packageManager: "pnpm@10.0.0" }),
);
await runGit(sourceRoot, "add", "package.json");
await commitGit(sourceRoot, "base");
const baseSha = await runGit(sourceRoot, "rev-parse", "HEAD");
await commitGit(sourceRoot, "target");
const targetSha = await runGit(sourceRoot, "rev-parse", "HEAD");
await runGit(base, "clone", "--quiet", sourceRoot, localRoot);
await runGit(localRoot, "checkout", "--detach", targetSha);
const fallback = { currentSha: targetSha, upstreamRef: "origin/main" };
const readStatus = (params: { fetch?: boolean; fallback?: typeof fallback } = {}) =>
checkUpdateStatus({
root: localRoot,
includeRegistry: false,
fetchGit: params.fetch ?? false,
timeoutMs: 5000,
...(params.fallback ? { gitUpstreamFallback: params.fallback } : {}),
});
const current = await readStatus({ fetch: true, fallback });
expect(current.git).toMatchObject({
branch: "HEAD",
sha: targetSha,
upstream: "origin/main",
upstreamSource: "receipt",
upstreamSha: targetSha,
ahead: 0,
behind: 0,
});
await commitGit(sourceRoot, "newer");
const newerSha = await runGit(sourceRoot, "rev-parse", "HEAD");
const behind = await readStatus({ fetch: true, fallback });
expect(behind.git).toMatchObject({
upstreamSource: "receipt",
upstreamSha: newerSha,
ahead: 0,
behind: 1,
});
for (const fallbackOverride of [undefined, { ...fallback, currentSha: baseSha }]) {
const unmanaged = await readStatus({ fallback: fallbackOverride });
expect(unmanaged.git).toMatchObject({ branch: "HEAD", upstream: null });
expect(unmanaged.git).not.toHaveProperty("upstreamSource");
}
await runGit(localRoot, "checkout", "-b", "receipt-collision", targetSha);
const namedBranch = await readStatus({ fallback });
expect(namedBranch.git).toMatchObject({
branch: "receipt-collision",
sha: targetSha,
upstream: null,
upstreamSha: null,
ahead: null,
behind: null,
});
expect(namedBranch.git).not.toHaveProperty("upstreamSource");
});
});
it("does not treat stale remote refs as current when fetch fails", async () => {
await withTempDir({ prefix: "openclaw-update-check-fetch-failure-" }, async (base) => {
const remoteRoot = path.join(base, "remote");
+25 -5
View File
@@ -24,6 +24,7 @@ type GitUpdateStatus = {
tag: string | null;
branch: string | null;
upstream: string | null;
upstreamSource?: "tracking" | "receipt";
upstreamSha?: string | null;
commitAtMs?: number | null;
dirty: boolean | null;
@@ -228,6 +229,7 @@ async function checkGitUpdateStatus(params: {
root: string;
timeoutMs?: number;
fetch?: boolean;
upstreamFallback?: { currentSha: string; upstreamRef: string };
}): Promise<GitUpdateStatus> {
const timeoutMs = params.timeoutMs ?? 6000;
const root = path.resolve(params.root);
@@ -281,7 +283,21 @@ async function checkGitUpdateStatus(params: {
const tag = tagRes && tagRes.code === 0 ? tagRes.stdout.trim() : null;
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
const trackingUpstream =
upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() || null : null;
const receiptUpstream =
!trackingUpstream &&
branch === "HEAD" &&
sha &&
params.upstreamFallback?.currentSha.trim().toLowerCase() === sha.toLowerCase()
? params.upstreamFallback.upstreamRef.trim() || null
: null;
const upstream = trackingUpstream ?? receiptUpstream;
const upstreamSource = trackingUpstream
? ("tracking" as const)
: receiptUpstream
? ("receipt" as const)
: undefined;
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
@@ -293,13 +309,14 @@ async function checkGitUpdateStatus(params: {
const canCompareUpstream = !params.fetch || fetchOk === true;
// Freeze the post-fetch upstream for both graph queries. Resolve via @{upstream} rather than
// its display name so dashed remotes stay operands on older Git versions. Three-dot rev-list
// still counts disconnected or truncated histories, so require a visible common ancestor.
// Freeze the post-fetch upstream for both graph queries. Active tracking wins;
// a matching successful update receipt keeps intentional detached installs comparable.
const upstreamRevision =
upstreamSource === "tracking" ? "@{upstream}^{commit}" : `${upstream}^{commit}`;
const upstreamCommitRes =
canCompareUpstream && upstream && sha
? await runCommandWithTimeout(
["git", "-C", root, "rev-parse", "--verify", "@{upstream}^{commit}"],
["git", "-C", root, "rev-parse", "--verify", upstreamRevision],
{ timeoutMs },
).catch(() => null)
: null;
@@ -339,6 +356,7 @@ async function checkGitUpdateStatus(params: {
tag,
branch,
upstream,
...(upstreamSource ? { upstreamSource } : {}),
upstreamSha: upstreamCommit,
commitAtMs,
dirty,
@@ -590,6 +608,7 @@ export async function checkUpdateStatus(params: {
root: string | null;
timeoutMs?: number;
fetchGit?: boolean;
gitUpstreamFallback?: { currentSha: string; upstreamRef: string };
includeRegistry?: boolean;
registryChannel?: UpdateChannel;
resolveRegistryChannel?: (
@@ -639,6 +658,7 @@ export async function checkUpdateStatus(params: {
root,
timeoutMs,
fetch: Boolean(params.fetchGit),
upstreamFallback: params.gitUpstreamFallback,
})
: Promise.resolve(undefined),
checkDepsStatus({ root, manager: packageManager }),
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
applyDevUpdateTargetEnv,
devUpdateTargetFromGitCampaign,
parseDevUpdateTargetEnv,
resolveDevUpdateTargetRevision,
} from "./update-dev-target.js";
const TRACKED_VALUE =
"openclaw-dev-target:v1:eyJ1cHN0cmVhbVJlZiI6Im9yaWdpbi9tYWluIiwidXBzdHJlYW1TaGEiOiJmcm96ZW4tc2hhIn0";
describe("dev update target environment", () => {
it("preserves the legacy plain detached-ref contract", () => {
expect(parseDevUpdateTargetEnv({ OPENCLAW_UPDATE_DEV_TARGET_REF: " refs/tags/dev " })).toEqual({
status: "valid",
target: { mode: "detached", ref: "refs/tags/dev" },
});
});
it("distinguishes an absent target from an invalid one", () => {
expect(parseDevUpdateTargetEnv({})).toEqual({ status: "absent" });
expect(
parseDevUpdateTargetEnv({ OPENCLAW_UPDATE_DEV_TARGET_REF: "refs/heads/my branch" }),
).toEqual({ status: "invalid" });
});
it("serializes tracked targets deterministically through the existing env field", () => {
const env = applyDevUpdateTargetEnv(
{ KEEP: "value" },
{ mode: "tracked", upstreamRef: "origin/main", upstreamSha: "frozen-sha" },
);
expect(env).toEqual({ KEEP: "value", OPENCLAW_UPDATE_DEV_TARGET_REF: TRACKED_VALUE });
expect(parseDevUpdateTargetEnv(env)).toEqual({
status: "valid",
target: { mode: "tracked", upstreamRef: "origin/main", upstreamSha: "frozen-sha" },
});
});
it("projects campaign targets and resolves both target modes", () => {
const tracked = devUpdateTargetFromGitCampaign({
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
commitsBehind: 2,
});
expect(tracked).toEqual({
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
});
expect(resolveDevUpdateTargetRevision(tracked)).toBe("frozen-sha");
expect(resolveDevUpdateTargetRevision({ mode: "detached", ref: "refs/tags/dev" })).toBe(
"refs/tags/dev",
);
});
it.each([
"other-dev-target:v1:payload",
"openclaw-dev-target:v2:payload",
"openclaw-dev-target:v1:",
"openclaw-dev-target:v1:not+base64url",
`openclaw-dev-target:v1:${"a".repeat(4097)}`,
`openclaw-dev-target:v1:${Buffer.from("not-json").toString("base64url")}`,
`openclaw-dev-target:v1:${Buffer.from(JSON.stringify(["ref", "origin/main"])).toString("base64url")}`,
`openclaw-dev-target:v1:${Buffer.from(JSON.stringify({ mode: "tracked", upstreamRef: "origin/main", upstreamSha: "ref" })).toString("base64url")}`,
`openclaw-dev-target:v1:${Buffer.from(JSON.stringify({ upstreamRef: " upstream ", upstreamSha: "ref" })).toString("base64url")}`,
`openclaw-dev-target:v1:${Buffer.from(JSON.stringify({ upstreamRef: "origin/main", upstreamSha: "ref\0" })).toString("base64url")}`,
])("fails closed for malformed or unsupported tracked value %s", (value) => {
expect(parseDevUpdateTargetEnv({ OPENCLAW_UPDATE_DEV_TARGET_REF: value })).toEqual({
status: "invalid",
});
});
});
+113
View File
@@ -0,0 +1,113 @@
import { safeParseJson, stableStringify } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { UpdateScheduleState } from "../../packages/gateway-protocol/src/index.js";
export const UPDATE_DEV_TARGET_REF_ENV = "OPENCLAW_UPDATE_DEV_TARGET_REF";
const TRACKED_DEV_TARGET_PREFIX = "openclaw-dev-target:v1:";
const MAX_TRACKED_DEV_TARGET_PAYLOAD_LENGTH = 4096;
export type DevUpdateTarget =
| { mode: "detached"; ref: string }
| { mode: "tracked"; upstreamRef: string; upstreamSha: string };
export type TrackedDevUpdateTarget = Extract<DevUpdateTarget, { mode: "tracked" }>;
type GitUpdateCampaignTarget = Extract<NonNullable<UpdateScheduleState["target"]>, { kind: "git" }>;
type DevUpdateTargetEnvParseResult =
| { status: "absent" }
| { status: "valid"; target: DevUpdateTarget }
| { status: "invalid" };
function isValidTargetPart(value: unknown): value is string {
return (
typeof value === "string" &&
value.length > 0 &&
!/\s/u.test(value) &&
Array.from(value).every((char) => {
const code = char.charCodeAt(0);
return code >= 0x20 && code !== 0x7f;
})
);
}
function parseTrackedTarget(payload: string): TrackedDevUpdateTarget | undefined {
if (
payload.length === 0 ||
payload.length > MAX_TRACKED_DEV_TARGET_PAYLOAD_LENGTH ||
!/^[A-Za-z0-9_-]+$/.test(payload)
) {
return undefined;
}
try {
const json = Buffer.from(payload, "base64url").toString("utf8");
if (Buffer.from(json, "utf8").toString("base64url") !== payload) {
return undefined;
}
const decoded = safeParseJson(json);
if (
!isRecord(decoded) ||
Object.keys(decoded).length !== 2 ||
!("upstreamRef" in decoded) ||
!("upstreamSha" in decoded)
) {
return undefined;
}
const { upstreamRef, upstreamSha } = decoded;
if (!isValidTargetPart(upstreamRef) || !isValidTargetPart(upstreamSha)) {
return undefined;
}
return { mode: "tracked", upstreamRef, upstreamSha };
} catch {
return undefined;
}
}
export function resolveDevUpdateTargetRevision(target: DevUpdateTarget): string {
return target.mode === "tracked" ? target.upstreamSha : target.ref;
}
export function devUpdateTargetFromGitCampaign(
target: GitUpdateCampaignTarget,
): TrackedDevUpdateTarget {
return {
mode: "tracked",
upstreamRef: target.upstreamRef,
upstreamSha: target.upstreamSha,
};
}
export function parseDevUpdateTargetEnv(env: NodeJS.ProcessEnv): DevUpdateTargetEnvParseResult {
const value = env[UPDATE_DEV_TARGET_REF_ENV]?.trim();
if (!value) {
return { status: "absent" };
}
if (value.startsWith(TRACKED_DEV_TARGET_PREFIX)) {
const target = parseTrackedTarget(value.slice(TRACKED_DEV_TARGET_PREFIX.length));
return target ? { status: "valid", target } : { status: "invalid" };
}
if (value.includes(":")) {
return { status: "invalid" };
}
return isValidTargetPart(value)
? { status: "valid", target: { mode: "detached", ref: value } }
: { status: "invalid" };
}
export function applyDevUpdateTargetEnv(
env: NodeJS.ProcessEnv,
target: DevUpdateTarget,
): NodeJS.ProcessEnv {
// Preserve the one-env handoff and shipped plain-ref contract; the namespaced
// tracked encoding cannot be silently reinterpreted as a Git ref.
const value =
target.mode === "tracked"
? `${TRACKED_DEV_TARGET_PREFIX}${Buffer.from(
stableStringify({
upstreamRef: target.upstreamRef,
upstreamSha: target.upstreamSha,
}),
"utf8",
).toString("base64url")}`
: target.ref;
return { ...env, [UPDATE_DEV_TARGET_REF_ENV]: value };
}
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseDevUpdateTargetEnv, type DevUpdateTarget } from "./update-dev-target.js";
const spawnMock = vi.hoisted(() => vi.fn());
const tempDirs = new Set<string>();
@@ -44,7 +45,13 @@ afterEach(async () => {
async function startHandoffAndReadCommand(params: {
channel: "beta" | "extended-stable";
tag?: string;
}): Promise<{ command: string; commandArgv: string[] | undefined }> {
devTarget?: DevUpdateTarget;
env?: NodeJS.ProcessEnv;
}): Promise<{
command: string;
commandArgv: string[] | undefined;
spawnEnv: NodeJS.ProcessEnv | undefined;
}> {
const { startManagedServiceUpdateHandoff } = await import("./update-managed-service-handoff.js");
const result = await startManagedServiceUpdateHandoff({
root: "/tmp/openclaw",
@@ -55,8 +62,12 @@ async function startHandoffAndReadCommand(params: {
execPath: "/usr/local/bin/node",
argv1: "/opt/openclaw/openclaw.mjs",
meta: {},
...(params.devTarget ? { devTarget: params.devTarget } : {}),
...(params.env ? { env: params.env } : {}),
});
const spawnCall = spawnMock.mock.calls[0] as unknown as [string, string[]] | undefined;
const spawnCall = spawnMock.mock.calls[0] as unknown as
| [string, string[], { env?: NodeJS.ProcessEnv }]
| undefined;
const paramsPath = spawnCall?.[1]?.[1];
if (!paramsPath) {
throw new Error("expected managed-service handoff params path");
@@ -72,7 +83,11 @@ async function startHandoffAndReadCommand(params: {
expect(metaFile.meta?.root).toBe(
await fs.realpath("/tmp/openclaw").catch(() => path.resolve("/tmp/openclaw")),
);
return { command: result.command, commandArgv: helperParams.commandArgv };
return {
command: result.command,
commandArgv: helperParams.commandArgv,
spawnEnv: spawnCall?.[2]?.env,
};
}
describe("managed service update handoff command", () => {
@@ -111,4 +126,29 @@ describe("managed service update handoff command", () => {
expect(result.command).toContain("--tag 2.0.0-beta.1");
expect(result.command).toContain("--channel beta");
});
it("merges a tracked target into the child environment without replacing caller fields", async () => {
const result = await startHandoffAndReadCommand({
channel: "beta",
env: {
KEEP: "value",
OPENCLAW_UPDATE_DEV_TARGET_REF: "stale-ref",
},
devTarget: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
},
});
expect(result.spawnEnv?.KEEP).toBe("value");
expect(parseDevUpdateTargetEnv(result.spawnEnv ?? {})).toEqual({
status: "valid",
target: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "frozen-sha",
},
});
});
});
+4 -1
View File
@@ -18,6 +18,7 @@ import {
CONTROL_PLANE_UPDATE_SENTINEL_META_ENV,
type ControlPlaneUpdateSentinelMetaFile,
} from "./update-control-plane-sentinel.js";
import { applyDevUpdateTargetEnv, type DevUpdateTarget } from "./update-dev-target.js";
import { resolveUpdateInstallRoot } from "./update-install-root.js";
import { MANAGED_SERVICE_UPDATE_HANDOFF_TEMP_PREFIX } from "./update-managed-service-handoff-cleanup.js";
import type { UpdateRestartSentinelMeta } from "./update-restart-sentinel-payload.js";
@@ -679,6 +680,7 @@ type ManagedServiceUpdateHandoffParams = {
handoffId?: string;
supervisor?: RespawnSupervisor | null;
env?: NodeJS.ProcessEnv;
devTarget?: DevUpdateTarget;
execPath?: string;
argv1?: string;
parentPid?: number;
@@ -1026,11 +1028,12 @@ async function spawnManagedServiceUpdateHandoff(
await fs.writeFile(paramsPath, `${JSON.stringify(helperParams, null, 2)}\n`, { mode: 0o600 });
await fs.writeFile(metaPath, `${JSON.stringify(metaFile, null, 2)}\n`, { mode: 0o600 });
const env = {
const childEnv = {
...stripSupervisorHintEnv(params.env ?? process.env),
[CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: metaPath,
OPENCLAW_UPDATE_RUN_HANDOFF: "1",
};
const env = params.devTarget ? applyDevUpdateTargetEnv(childEnv, params.devTarget) : childEnv;
const spawnTarget = await resolveHandoffSpawn({
supervisor: params.supervisor,
env,
+25 -2
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { trimLogTail } from "./restart-sentinel.js";
import { DEV_BRANCH } from "./update-channels.js";
import { resolveDevUpdateTargetRevision, type DevUpdateTarget } from "./update-dev-target.js";
import {
managerInstallArgs,
managerInstallIgnoreScriptsArgs,
@@ -413,7 +414,7 @@ async function testPreflightCandidates(params: {
export async function runGitDevPreflight(params: {
gitRoot: string;
devTargetRef?: string;
devTarget?: DevUpdateTarget;
needsCheckoutMain: boolean;
runCommand: CommandRunner;
timeoutMs: number;
@@ -421,7 +422,9 @@ export async function runGitDevPreflight(params: {
steps: UpdateStepResult[];
step: StepFactory;
}): Promise<GitDevPreflightResult> {
const devTargetRef = normalizeDevTargetRef(params.devTargetRef);
const devTargetRef = params.devTarget
? normalizeDevTargetRef(resolveDevUpdateTargetRevision(params.devTarget))
: null;
let preflightBaseSha: string;
let candidates: string[];
let selectedDevUpstream: string | null = null;
@@ -433,6 +436,26 @@ export async function runGitDevPreflight(params: {
}
preflightBaseSha = targetSha;
candidates = [targetSha];
if (params.devTarget?.mode === "tracked") {
const ancestryStep = await runStep(
params.step(
"tracked target ancestry",
[
"git",
"-C",
params.gitRoot,
"merge-base",
"--is-ancestor",
targetSha,
`${params.devTarget.upstreamRef}^{commit}`,
],
params.gitRoot,
),
);
if (ancestryStep.exitCode !== 0) {
return { status: "error", reason: "tracked-upstream-invalid" };
}
}
} else {
const upstream = await resolveUpstreamCandidates(params);
if (upstream.status !== "ok") {
+18 -5
View File
@@ -68,8 +68,9 @@ export async function runGitUpdate(params: {
const beforeSha = beforeShaResult.stdout.trim() || null;
const beforeVersion = await readPackageVersion(gitRoot);
const branch = await readBranchName(runCommand, gitRoot, timeoutMs);
const hasDevTargetRef = channel === "dev" && Boolean(opts.devTargetRef?.trim());
const needsCheckoutMain = channel === "dev" && !hasDevTargetRef && branch !== DEV_BRANCH;
const devTarget = channel === "dev" ? opts.devTarget : undefined;
const hasDevTarget = devTarget !== undefined;
const needsCheckoutMain = channel === "dev" && !hasDevTarget && branch !== DEV_BRANCH;
const totalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9;
const steps: UpdateStepResult[] = [];
let stepIndex = 0;
@@ -95,6 +96,7 @@ export async function runGitUpdate(params: {
let allowGatewayActivation = opts.allowGatewayActivation === true;
let mutationPrepared = false;
let createdDevBranchDuringUpdate = false;
let devPreflight: Awaited<ReturnType<typeof runGitDevPreflight>> | undefined;
let liveBuildStarted = false;
let recovery: UpdateRunResult["recovery"];
const prepareMutation = async (revision: string) => {
@@ -283,9 +285,9 @@ export async function runGitUpdate(params: {
if (fetchFailure) {
return fetchFailure;
}
const preflight = await runGitDevPreflight({
devPreflight = await runGitDevPreflight({
gitRoot,
devTargetRef: opts.devTargetRef,
devTarget,
needsCheckoutMain,
runCommand,
timeoutMs,
@@ -293,11 +295,12 @@ export async function runGitUpdate(params: {
steps,
step,
});
const preflight = devPreflight;
if (preflight.status !== "ok") {
return buildError(preflight.reason, preflight.status);
}
await prepareMutation(preflight.selectedSha);
if (hasDevTargetRef) {
if (hasDevTarget) {
const failure = await runRequiredStep(
`git checkout ${preflight.selectedSha}`,
["git", "-C", gitRoot, "checkout", "--detach", preflight.selectedSha],
@@ -553,6 +556,13 @@ export async function runGitUpdate(params: {
const afterShaStep = await runStep(
step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot),
);
if (
devTarget?.mode === "tracked" &&
devPreflight?.status === "ok" &&
afterShaStep.stdoutTail?.trim() !== devPreflight.selectedSha
) {
return await rollbackError("target-sha-mismatch");
}
return {
status: failedStep ? "error" : "ok",
mode: "git",
@@ -562,6 +572,9 @@ export async function runGitUpdate(params: {
after: {
sha: afterShaStep.stdoutTail?.trim() ?? null,
version: await readPackageVersion(gitRoot),
...(!failedStep && devTarget?.mode === "tracked"
? { upstreamRef: devTarget.upstreamRef }
: {}),
},
steps,
durationMs: Date.now() - startedAt,
+3 -2
View File
@@ -2,6 +2,7 @@ import type { CommandOptions } from "../process/exec.js";
import type { OpenClawSchemaVersions } from "../state/openclaw-schema-versions.js";
import type { PackageUpdateStepAdvisory } from "./package-update-steps.js";
import type { UpdateChannel } from "./update-channels.js";
import type { DevUpdateTarget } from "./update-dev-target.js";
import type { GlobalInstallManager } from "./update-global.js";
export type UpdateStepAdvisory = PackageUpdateStepAdvisory;
@@ -26,7 +27,7 @@ export type UpdateRunResult = {
root?: string;
reason?: string;
before?: { sha?: string | null; version?: string | null };
after?: { sha?: string | null; version?: string | null };
after?: { sha?: string | null; version?: string | null; upstreamRef?: string };
steps: UpdateStepResult[];
durationMs: number;
recovery?:
@@ -128,7 +129,7 @@ export type UpdateRunnerOptions = {
argv1?: string;
tag?: string;
channel?: UpdateChannel;
devTargetRef?: string;
devTarget?: DevUpdateTarget;
deferConfiguredPluginInstallRepair?: boolean;
allowGatewayServiceRepair?: boolean;
allowGatewayActivation?: boolean;
+193 -9
View File
@@ -5,12 +5,14 @@ import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts";
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { withEnvAsync } from "../test-utils/env.js";
import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js";
import { pathExists } from "../utils.js";
import { resolveStableNodePath } from "./stable-node-path.js";
import type { UpdateChannel } from "./update-channels.js";
import type { DevUpdateTarget } from "./update-dev-target.js";
import {
resolveUpdateDoctorExecutionPolicy,
resolveUpdateInstallSurface,
@@ -516,6 +518,73 @@ describe("runGatewayUpdate", () => {
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
}
async function runRealGit(cwd: string, ...args: string[]): Promise<string> {
const result = await runCommandWithTimeout(["git", ...args], { cwd, timeoutMs: 5000 });
if (result.code !== 0) {
throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
}
return result.stdout.trim();
}
async function createTrackedGitFixture(detached: boolean) {
const sourceRoot = await fixtureRootTracker.make("tracked-source");
const localRoot = await fixtureRootTracker.make("tracked-local");
await runRealGit(sourceRoot, "init", "--initial-branch=main");
await runRealGit(sourceRoot, "config", "user.name", "OpenClaw Test");
await runRealGit(sourceRoot, "config", "user.email", "openclaw@example.com");
await fs.writeFile(
path.join(sourceRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@10.0.0" }),
);
await fs.writeFile(path.join(sourceRoot, "openclaw.mjs"), "export {};\n");
await fs.writeFile(path.join(sourceRoot, "README.md"), "base\n");
await runRealGit(sourceRoot, "add", "package.json", "openclaw.mjs", "README.md");
await runRealGit(sourceRoot, "commit", "-m", "base");
const baseSha = await runRealGit(sourceRoot, "rev-parse", "HEAD");
await runRealGit(path.dirname(localRoot), "clone", "--quiet", sourceRoot, localRoot);
await runRealGit(localRoot, "config", "user.name", "OpenClaw Test");
await runRealGit(localRoot, "config", "user.email", "openclaw@example.com");
if (detached) {
await runRealGit(localRoot, "checkout", "--detach", baseSha);
}
await fs.writeFile(path.join(sourceRoot, "README.md"), "target\n");
await runRealGit(sourceRoot, "add", "README.md");
await runRealGit(sourceRoot, "commit", "-m", "target");
const targetSha = await runRealGit(sourceRoot, "rev-parse", "HEAD");
return { sourceRoot, localRoot, baseSha, targetSha };
}
function createRealGitUpdateRunner(params: { finalHead?: { root: string; sha: string } } = {}) {
let headReads = 0;
return async (argv: string[], options: { cwd?: string; timeoutMs?: number }) => {
if (argv[0] === "git") {
const finalHead = params.finalHead;
if (
finalHead &&
argv[2] === finalHead.root &&
argv[3] === "rev-parse" &&
argv[4] === "HEAD"
) {
headReads += 1;
if (headReads === 2) {
return toCommandResult({ stdout: finalHead.sha });
}
}
return await runCommandWithTimeout(argv, {
cwd: options.cwd,
timeoutMs: options.timeoutMs ?? 5000,
});
}
if (argv[0] === "pnpm" && (argv[1] === "build" || argv[1] === "ui:build")) {
const cwd = options.cwd ?? process.cwd();
const uiDir = path.join(cwd, "dist", "control-ui");
await fs.mkdir(uiDir, { recursive: true });
await fs.writeFile(path.join(uiDir, "index.html"), "ok\n");
}
return toCommandResult();
};
}
async function runWithCommand(
runCommand: (
argv: string[],
@@ -525,7 +594,7 @@ describe("runGatewayUpdate", () => {
channel?: UpdateChannel;
tag?: string;
cwd?: string;
devTargetRef?: string;
devTarget?: DevUpdateTarget;
deferConfiguredPluginInstallRepair?: boolean;
allowGatewayServiceRepair?: boolean;
allowGatewayActivation?: boolean;
@@ -543,7 +612,7 @@ describe("runGatewayUpdate", () => {
timeoutMs: 5000,
...(options?.channel ? { channel: options.channel } : {}),
...(options?.tag ? { tag: options.tag } : {}),
...(options?.devTargetRef ? { devTargetRef: options.devTargetRef } : {}),
...(options?.devTarget ? { devTarget: options.devTarget } : {}),
...(options?.deferConfiguredPluginInstallRepair
? { deferConfiguredPluginInstallRepair: true }
: {}),
@@ -561,7 +630,7 @@ describe("runGatewayUpdate", () => {
channel?: UpdateChannel;
tag?: string;
cwd?: string;
devTargetRef?: string;
devTarget?: DevUpdateTarget;
deferConfiguredPluginInstallRepair?: boolean;
beforeGitMutation?: (target: {
schemaVersions?: { state: number; agent: number };
@@ -723,7 +792,7 @@ describe("runGatewayUpdate", () => {
it.each([
{ name: "upstream", options: {} },
{ name: "target ref", options: { devTargetRef: "main" } },
{ name: "target ref", options: { devTarget: { mode: "detached", ref: "main" } } },
] as const)("stops dev update when fetch fails before resolving $name", async ({ options }) => {
await setupGitCheckout();
const fetchCommand = `git -C ${tempDir} fetch --all --prune --no-tags`;
@@ -1092,7 +1161,7 @@ describe("runGatewayUpdate", () => {
const result = await runWithRunner(runner, {
channel: "dev",
devTargetRef: "refs/tags/v2026.5.19-beta.2",
devTarget: { mode: "detached", ref: "refs/tags/v2026.5.19-beta.2" },
});
expect(result.status).toBe("ok");
@@ -1118,7 +1187,7 @@ describe("runGatewayUpdate", () => {
const result = await runWithRunner(runner, {
channel: "dev",
devTargetRef: "refs/tags/v2026.5.19-beta.2",
devTarget: { mode: "detached", ref: "refs/tags/v2026.5.19-beta.2" },
});
expect(result.status).toBe("error");
@@ -2194,13 +2263,122 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
};
const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: targetSha });
const result = await runWithCommand(runCommand, {
channel: "dev",
devTarget: { mode: "detached", ref: targetSha },
});
expect(result.status).toBe("ok");
expect(calls).toContain(`git -C ${tempDir} rev-parse ${targetSha}`);
expect(calls).toContain(`git -C ${tempDir} checkout --detach ${targetSha}`);
expect(calls).not.toContain(`git -C ${tempDir} rev-parse @{upstream}`);
expect(calls).not.toContain(`git -C ${tempDir} rebase ${targetSha}`);
expect(result.after).not.toHaveProperty("upstreamRef");
});
it.each([
{ name: "main", detached: false },
{ name: "detached HEAD", detached: true },
])(
"keeps a tracked dev target detached from $name and records its verified upstream",
async ({ detached }) => {
const { localRoot, targetSha } = await createTrackedGitFixture(detached);
const result = await runGatewayUpdate({
cwd: localRoot,
channel: "dev",
devTarget: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: targetSha,
},
timeoutMs: 5000,
runCommand: createRealGitUpdateRunner(),
});
expect(result.status).toBe("ok");
expect(result.after).toMatchObject({ sha: targetSha, upstreamRef: "origin/main" });
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "HEAD")).toBe("HEAD");
expect(await runRealGit(localRoot, "rev-parse", "HEAD")).toBe(targetSha);
},
);
it("refuses a tracked target that is unrelated to its authoritative upstream", async () => {
const { sourceRoot, localRoot, baseSha, targetSha } = await createTrackedGitFixture(false);
await runRealGit(sourceRoot, "checkout", "-b", "unrelated", baseSha);
await fs.writeFile(path.join(sourceRoot, "README.md"), "unrelated\n");
await runRealGit(sourceRoot, "add", "README.md");
await runRealGit(sourceRoot, "commit", "-m", "unrelated target");
const beforeGitMutation = vi.fn<() => Promise<void>>();
const result = await runGatewayUpdate({
cwd: localRoot,
channel: "dev",
devTarget: {
mode: "tracked",
upstreamRef: "origin/unrelated",
upstreamSha: targetSha,
},
timeoutMs: 5000,
runCommand: createRealGitUpdateRunner(),
beforeGitMutation,
});
expect(result.status).toBe("error");
expect(result.reason).toBe("tracked-upstream-invalid");
expect(beforeGitMutation).not.toHaveBeenCalled();
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main");
expect(await runRealGit(localRoot, "rev-parse", "HEAD")).toBe(baseSha);
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "@{upstream}")).toBe(
"origin/main",
);
});
it("refuses a tracked target whose authoritative upstream is missing", async () => {
const { localRoot, baseSha, targetSha } = await createTrackedGitFixture(false);
const beforeGitMutation = vi.fn<() => Promise<void>>();
const result = await runGatewayUpdate({
cwd: localRoot,
channel: "dev",
devTarget: {
mode: "tracked",
upstreamRef: "origin/missing",
upstreamSha: targetSha,
},
timeoutMs: 5000,
runCommand: createRealGitUpdateRunner(),
beforeGitMutation,
});
expect(result.status).toBe("error");
expect(result.reason).toBe("tracked-upstream-invalid");
expect(beforeGitMutation).not.toHaveBeenCalled();
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main");
expect(await runRealGit(localRoot, "rev-parse", "HEAD")).toBe(baseSha);
expect(await runRealGit(localRoot, "rev-parse", "--abbrev-ref", "@{upstream}")).toBe(
"origin/main",
);
});
it("rejects a tracked result whose final HEAD differs from the frozen SHA", async () => {
const { localRoot, baseSha, targetSha } = await createTrackedGitFixture(false);
const result = await runGatewayUpdate({
cwd: localRoot,
channel: "dev",
devTarget: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: targetSha,
},
timeoutMs: 5000,
runCommand: createRealGitUpdateRunner({ finalHead: { root: localRoot, sha: baseSha } }),
});
expect(result.status).toBe("error");
expect(result.reason).toBe("target-sha-mismatch");
expect(result.after).toBeUndefined();
});
it("resolves symbolic dev target refs from the fetched remote branch", async () => {
@@ -2275,7 +2453,10 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
};
const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: "main" });
const result = await runWithCommand(runCommand, {
channel: "dev",
devTarget: { mode: "detached", ref: "main" },
});
expect(result.status).toBe("ok");
expect(calls).toContain(`git -C ${tempDir} rev-parse refs/remotes/origin/main`);
@@ -2359,7 +2540,10 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 0 };
};
const result = await runWithCommand(runCommand, { channel: "dev", devTargetRef: "main" });
const result = await runWithCommand(runCommand, {
channel: "dev",
devTarget: { mode: "detached", ref: "main" },
});
expect(result.status).toBe("ok");
expect(calls).toContain(`git -C ${tempDir} rev-parse --show-toplevel`);
+117 -11
View File
@@ -21,6 +21,7 @@ import {
} from "./kysely-sync.js";
import { writeUpdateInstallReceiptRowSync } from "./restart-sentinel-store.js";
import type { UpdateCheckResult } from "./update-check.js";
import { parseDevUpdateTargetEnv } from "./update-dev-target.js";
const {
detectRespawnSupervisorMock,
@@ -327,13 +328,16 @@ describe("update-startup", () => {
function mockDevGitStatus(params?: {
currentSha?: string;
branch?: string | null;
upstream?: string | null;
upstreamSource?: "tracking" | "receipt";
upstreamSha?: string | null;
commitAtMs?: number | null;
ahead?: number | null;
behind?: number | null;
fetchOk?: boolean;
}) {
const upstream = params?.upstream === undefined ? "origin/main" : params.upstream;
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: "/opt/openclaw",
@@ -343,8 +347,13 @@ describe("update-startup", () => {
root: "/opt/openclaw",
sha: params?.currentSha ?? "current-sha",
tag: null,
branch: "main",
upstream: params?.upstream === undefined ? "origin/main" : params.upstream,
branch: params?.branch === undefined ? "main" : params.branch,
upstream,
...(params?.upstreamSource
? { upstreamSource: params.upstreamSource }
: upstream
? { upstreamSource: "tracking" as const }
: {}),
upstreamSha: params?.upstreamSha === undefined ? "upstream-sha" : params.upstreamSha,
commitAtMs: params?.commitAtMs ?? null,
dirty: false,
@@ -1141,7 +1150,11 @@ describe("update-startup", () => {
timeoutMs: 45 * 60 * 1000,
restartDrainTimeoutMs: 300_000,
root: "/opt/openclaw",
devTargetSha: "upstream-sha",
devTarget: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "upstream-sha",
},
});
});
@@ -1165,10 +1178,18 @@ describe("update-startup", () => {
const updateCall = vi
.mocked(runCommandWithTimeout)
.mock.calls.find(([argv]) => argv.includes("update"));
expect(updateCall).toBeDefined();
expect(updateCall?.[1]).toMatchObject({
timeoutMs: 45 * 60 * 1000,
env: { OPENCLAW_UPDATE_DEV_TARGET_REF: "frozen-upstream-sha" },
const updateOptions = updateCall?.[1];
if (!updateOptions || typeof updateOptions === "number") {
throw new Error("expected update command options");
}
expect(updateOptions.timeoutMs).toBe(45 * 60 * 1000);
expect(parseDevUpdateTargetEnv(updateOptions.env ?? {})).toEqual({
status: "valid",
target: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "frozen-upstream-sha",
},
});
});
@@ -1186,12 +1207,85 @@ describe("update-startup", () => {
await vi.advanceTimersByTimeAsync(60_000);
const [handoffParams] = startManagedServiceUpdateHandoffMock.mock.calls[0] ?? [];
expect(handoffParams?.env).toEqual({
...process.env,
OPENCLAW_UPDATE_DEV_TARGET_REF: "frozen-upstream-sha",
expect(handoffParams?.devTarget).toEqual({
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "frozen-upstream-sha",
});
});
it("continues automatic dev campaigns from receipt-backed detached HEAD", async () => {
runOpenClawStateWriteTransaction(({ db }) => {
writeUpdateInstallReceiptRowSync(db, {
kind: "update",
status: "ok",
ts: Date.now() - 60_000,
stats: {
mode: "git",
root: "/opt/openclaw",
after: {
sha: "current-sha",
version: "1.0.0",
upstreamRef: "origin/main",
},
},
});
});
mockDevGitStatus({ branch: "HEAD", upstreamSource: "receipt" });
const runAutoUpdate = createAutoUpdateSuccessMock();
await runGatewayUpdateCheck({
cfg: { update: { channel: "dev", auto: { enabled: true } } },
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
activeWorkInspectors: idleActiveWorkInspectors(),
runAutoUpdate,
});
expect(checkUpdateStatus).toHaveBeenCalledWith({
root: "/opt/openclaw",
timeoutMs: 2500,
fetchGit: true,
includeRegistry: false,
gitUpstreamFallback: { currentSha: "current-sha", upstreamRef: "origin/main" },
});
expect(getUpdateSchedule()?.campaign?.state).toBe("countdown");
await vi.advanceTimersByTimeAsync(60_000);
expect(runAutoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
devTarget: {
mode: "tracked",
upstreamRef: "origin/main",
upstreamSha: "upstream-sha",
},
}),
);
});
it.each([
{ name: "ahead", git: { ahead: 1, behind: 0 } },
{ name: "diverged", git: { ahead: 1, behind: 2 } },
{ name: "non-main", git: { branch: "feature" } },
{ name: "detached", git: { branch: "HEAD" } },
])("does not announce an automatic dev campaign for a $name checkout", async ({ git }) => {
mockDevGitStatus(git);
const runAutoUpdate = createAutoUpdateSuccessMock();
await runGatewayUpdateCheck({
cfg: { update: { channel: "dev", auto: { enabled: true } } },
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
activeWorkInspectors: idleActiveWorkInspectors(),
runAutoUpdate,
});
await vi.advanceTimersByTimeAsync(15 * 60_000);
expect(getUpdateSchedule()?.campaign).toBeUndefined();
expect(runAutoUpdate).not.toHaveBeenCalled();
});
it("does not probe dev commits when the checkout is up to date", async () => {
mockDevGitStatus({ behind: 0 });
@@ -1221,7 +1315,7 @@ describe("update-startup", () => {
stats: {
mode: "git",
root: "/opt/openclaw",
after: { sha: "current-sha", version: "1.0.0" },
after: { sha: "current-sha", version: "1.0.0", upstreamRef: "origin/main" },
},
});
});
@@ -1282,6 +1376,18 @@ describe("update-startup", () => {
git: { upstream: null, upstreamSha: null, ahead: null, behind: null },
expected: { status: "unavailable", reason: "no-upstream" },
},
{
name: "missing receipt-backed upstream ref",
git: {
branch: "HEAD",
upstream: "origin/missing",
upstreamSource: "receipt" as const,
upstreamSha: null,
ahead: null,
behind: null,
},
expected: { status: "unavailable", reason: "no-upstream-sha" },
},
{
name: "incomparable history",
git: { ahead: null, behind: null },
+58 -76
View File
@@ -36,7 +36,10 @@ import {
getNodeSqliteKysely,
} from "./kysely-sync.js";
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
import { readUpdateInstallReceipt, type RestartSentinelPayload } from "./restart-sentinel.js";
import {
readSuccessfulGitUpdateReceipt,
type SuccessfulGitUpdateReceipt,
} from "./restart-sentinel.js";
import {
resolveGatewayRestartDeferralTimeoutMs,
scheduleGatewaySigusr1Restart,
@@ -45,6 +48,7 @@ import { detectRespawnSupervisor, type RespawnSupervisor } from "./supervisor-ma
import { gatewayUpdateCampaign, type UpdateCampaignController } from "./update-campaign.js";
import {
channelToNpmTag,
DEV_BRANCH,
normalizeUpdateChannel,
resolveEffectiveUpdateChannel,
DEFAULT_PACKAGE_CHANNEL,
@@ -57,6 +61,11 @@ import {
type UpdateCheckResult,
} from "./update-check.js";
import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "./update-control-plane-sentinel.js";
import {
applyDevUpdateTargetEnv,
devUpdateTargetFromGitCampaign,
type TrackedDevUpdateTarget,
} from "./update-dev-target.js";
import { updateInstallRootsMatch } from "./update-install-root.js";
import { startManagedServiceUpdateHandoff } from "./update-managed-service-handoff.js";
@@ -93,14 +102,16 @@ type AutoUpdateRunResult = {
logPath?: string;
};
type AutoUpdateRunner = (params: {
type AutoUpdateRunParams = {
channel: "stable" | "beta" | "dev";
timeoutMs: number;
restartDrainTimeoutMs: number | undefined;
root?: string;
packageTargetVersion?: string;
devTargetSha?: string;
}) => Promise<AutoUpdateRunResult>;
devTarget?: TrackedDevUpdateTarget;
};
type AutoUpdateRunner = (params: AutoUpdateRunParams) => Promise<AutoUpdateRunResult>;
export type {
UpdateAvailable,
@@ -419,15 +430,9 @@ function resolveManagedAutoUpdateRestartDelayMs(supervisor: RespawnSupervisor):
return supervisor === "systemd" ? MANAGED_AUTO_UPDATE_SYSTEMD_RESTART_GRACE_MS : 0;
}
async function startManagedServiceAutoUpdateHandoff(params: {
channel: "stable" | "beta" | "dev";
timeoutMs: number;
restartDrainTimeoutMs: number | undefined;
root?: string;
packageTargetVersion?: string;
devTargetSha?: string;
supervisor: RespawnSupervisor;
}): Promise<AutoUpdateRunResult> {
async function startManagedServiceAutoUpdateHandoff(
params: AutoUpdateRunParams & { supervisor: RespawnSupervisor },
): Promise<AutoUpdateRunResult> {
const restartDelayMs = resolveManagedAutoUpdateRestartDelayMs(params.supervisor);
const handoffId = randomUUID();
try {
@@ -443,14 +448,7 @@ async function startManagedServiceAutoUpdateHandoff(params: {
restartDelayMs,
supervisor: params.supervisor,
handoffId,
...(params.devTargetSha
? {
env: {
...process.env,
OPENCLAW_UPDATE_DEV_TARGET_REF: params.devTargetSha,
},
}
: {}),
...(params.devTarget ? { devTarget: params.devTarget } : {}),
meta: {
handoffId,
note: "background auto-update",
@@ -482,14 +480,7 @@ async function startManagedServiceAutoUpdateHandoff(params: {
}
}
async function runAutoUpdateCommand(params: {
channel: "stable" | "beta" | "dev";
timeoutMs: number;
restartDrainTimeoutMs: number | undefined;
root?: string;
packageTargetVersion?: string;
devTargetSha?: string;
}): Promise<AutoUpdateRunResult> {
async function runAutoUpdateCommand(params: AutoUpdateRunParams): Promise<AutoUpdateRunResult> {
if (isGatewayExternallySupervised()) {
return {
ok: false,
@@ -507,7 +498,7 @@ async function runAutoUpdateCommand(params: {
restartDrainTimeoutMs: params.restartDrainTimeoutMs,
root: params.root,
...(params.packageTargetVersion ? { packageTargetVersion: params.packageTargetVersion } : {}),
...(params.devTargetSha ? { devTargetSha: params.devTargetSha } : {}),
...(params.devTarget ? { devTarget: params.devTarget } : {}),
supervisor,
});
}
@@ -555,9 +546,7 @@ async function runAutoUpdateCommand(params: {
try {
const res = await runCommandWithTimeout(argv, {
timeoutMs: params.timeoutMs,
...(params.devTargetSha
? { env: { OPENCLAW_UPDATE_DEV_TARGET_REF: params.devTargetSha } }
: {}),
...(params.devTarget ? { env: applyDevUpdateTargetEnv({}, params.devTarget) } : {}),
});
return {
ok: res.code === 0,
@@ -582,21 +571,26 @@ function clearAutoState(nextState: UpdateCheckState): void {
}
async function resolveStartupInstallStatus(fetchGit: boolean) {
const resolvedRoot = await resolveOpenClawPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
const [status, installReceipt] = await Promise.all([
checkUpdateStatus({
root: resolvedRoot,
timeoutMs: 2500,
fetchGit,
includeRegistry: false,
const [root, installReceipt] = await Promise.all([
resolveOpenClawPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
}),
readUpdateInstallReceipt(),
readSuccessfulGitUpdateReceipt(),
]);
return { root: resolvedRoot, status, installReceipt };
const gitUpstreamFallback =
installReceipt?.upstreamRef && root && updateInstallRootsMatch(root, installReceipt.root)
? { currentSha: installReceipt.sha, upstreamRef: installReceipt.upstreamRef }
: undefined;
const status = await checkUpdateStatus({
root,
timeoutMs: 2500,
fetchGit,
includeRegistry: false,
...(gitUpstreamFallback ? { gitUpstreamFallback } : {}),
});
return { root, status, installReceipt };
}
type GitScheduleStatus = NonNullable<NonNullable<UpdateScheduleState["install"]>["git"]>;
@@ -613,27 +607,21 @@ function gitCommitsMatch(left: string, right: string): boolean {
function resolveGitInstalledAtMs(
git: NonNullable<UpdateCheckResult["git"]>,
installReceipt: RestartSentinelPayload | null,
installReceipt: SuccessfulGitUpdateReceipt | null,
root: string | null,
): number | undefined {
const receiptSha = installReceipt?.stats?.after?.sha;
const receiptRoot = installReceipt?.stats?.root;
return installReceipt?.kind === "update" &&
installReceipt.status === "ok" &&
installReceipt.stats?.mode === "git" &&
typeof receiptSha === "string" &&
typeof receiptRoot === "string" &&
return installReceipt &&
root !== null &&
updateInstallRootsMatch(root, receiptRoot) &&
updateInstallRootsMatch(root, installReceipt.root) &&
git.sha &&
gitCommitsMatch(receiptSha, git.sha)
? installReceipt.ts
gitCommitsMatch(installReceipt.sha, git.sha)
? installReceipt.installedAtMs
: undefined;
}
function resolveGitScheduleStatus(
update: UpdateCheckResult,
installReceipt: RestartSentinelPayload | null,
installReceipt: SuccessfulGitUpdateReceipt | null,
root: string | null,
): GitScheduleStatus | undefined {
if (update.installKind !== "git") {
@@ -684,7 +672,7 @@ function withInstallStatus(
schedule: UpdateScheduleState,
update: UpdateCheckResult,
includeGitStatus: boolean,
installReceipt: RestartSentinelPayload | null,
installReceipt: SuccessfulGitUpdateReceipt | null,
root: string | null,
): UpdateScheduleState {
const git = includeGitStatus ? resolveGitScheduleStatus(update, installReceipt, root) : undefined;
@@ -756,19 +744,18 @@ async function resolveDevGitCommits(params: {
}
async function runCampaignUpdate(params: {
identity: string;
channel: "stable" | "beta" | "dev";
version: string;
tag: string;
forced: boolean;
root?: string;
devTargetSha?: string;
devTarget?: TrackedDevUpdateTarget;
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
runAuto: AutoUpdateRunner;
}): Promise<"handoff" | "applied" | "failed"> {
const attemptAt = resolveUpdateCheckNowMs(Date.now());
const attemptState = await readState();
attemptState.autoLastAttemptVersion = params.identity;
attemptState.autoLastAttemptVersion = params.version;
attemptState.autoLastAttemptAt = resolveUpdateCheckTimestamp(attemptAt);
await writeState(attemptState);
@@ -778,7 +765,7 @@ async function runCampaignUpdate(params: {
restartDrainTimeoutMs: resolveGatewayRestartDeferralTimeoutMs(),
...(params.root ? { root: params.root } : {}),
...(params.channel === "dev" ? {} : { packageTargetVersion: params.version }),
...(params.devTargetSha ? { devTargetSha: params.devTargetSha } : {}),
...(params.devTarget ? { devTarget: params.devTarget } : {}),
});
if (outcome.ok && outcome.reason === CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON) {
params.log.info("auto-update handoff started", {
@@ -793,7 +780,7 @@ async function runCampaignUpdate(params: {
}
if (outcome.ok) {
const successState = await readState();
successState.autoLastSuccessVersion = params.identity;
successState.autoLastSuccessVersion = params.version;
successState.autoLastSuccessAt = resolveUpdateCheckTimestamp(Date.now());
await writeState(successState);
params.log.info("auto-update applied", {
@@ -823,14 +810,7 @@ export async function runGatewayUpdateCheck(params: {
onUpdateScheduleChange?: (schedule: UpdateScheduleState) => void;
activeWorkInspectors?: Partial<GatewayActiveWorkInspectors>;
updateCampaign?: UpdateCampaignController;
runAutoUpdate?: (params: {
channel: "stable" | "beta" | "dev";
timeoutMs: number;
restartDrainTimeoutMs: number | undefined;
root?: string;
packageTargetVersion?: string;
devTargetSha?: string;
}) => Promise<AutoUpdateRunResult>;
runAutoUpdate?: AutoUpdateRunner;
}): Promise<void> {
if (shouldSkipCheck(Boolean(params.allowInTests))) {
return;
@@ -1144,7 +1124,11 @@ export async function runGatewayUpdateCheck(params: {
reason: EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON,
});
}
if (shouldRunAutoUpdate) {
const hasTrackedMain = git.branch === DEV_BRANCH && git.upstreamSource === "tracking";
const hasReceiptBackedDetachedHead = git.branch === "HEAD" && git.upstreamSource === "receipt";
const canRunTrackedDevCampaign =
(hasTrackedMain || hasReceiptBackedDetachedHead) && git.ahead === 0;
if (shouldRunAutoUpdate && canRunTrackedDevCampaign) {
const lastAttemptAt = state.autoLastAttemptAt ? Date.parse(state.autoLastAttemptAt) : null;
const recentAttempt =
state.autoLastAttemptVersion === upstreamSha &&
@@ -1159,13 +1143,12 @@ export async function runGatewayUpdateCheck(params: {
onChange: onCampaignChange,
apply: async ({ forced }) =>
await runCampaignUpdate({
identity: upstreamSha,
channel: "dev",
version: upstreamSha,
tag: "dev",
forced,
root: root ?? status.root ?? undefined,
devTargetSha: target.upstreamSha,
devTarget: devUpdateTargetFromGitCampaign(target),
log: params.log,
runAuto,
}),
@@ -1312,7 +1295,6 @@ export async function runGatewayUpdateCheck(params: {
onChange: onCampaignChange,
apply: async ({ forced }) =>
await runCampaignUpdate({
identity: resolvedVersion,
channel,
version: resolvedVersion,
tag,