diff --git a/openclaw.mjs b/openclaw.mjs index 3f097247df08..cb44b2eda1f2 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -457,16 +457,19 @@ function readLauncherJson(relativePath) { } function resolveLauncherVersion() { - const packageJson = readLauncherJson("./package.json"); - const packageVersion = normalizeLauncherMetadataValue(packageJson?.version); - if (packageVersion) { - return packageVersion; - } + // Report what is built, not what the source says: resolveLauncherCommit already + // prefers dist provenance, so reading package.json first pairs a source version + // with a built commit and hides a checkout that pulled without rebuilding. const buildInfo = readLauncherJson("./dist/build-info.json"); const buildVersion = normalizeLauncherMetadataValue(buildInfo?.version); if (buildVersion) { return buildVersion; } + const packageJson = readLauncherJson("./package.json"); + const packageVersion = normalizeLauncherMetadataValue(packageJson?.version); + if (packageVersion) { + return packageVersion; + } return normalizeLauncherMetadataValue(process.env.OPENCLAW_BUNDLED_VERSION) ?? "0.0.0"; } diff --git a/src/commands/status.update.test.ts b/src/commands/status.update.test.ts index 6cdd4c6fdfd2..8f90be261c3f 100644 --- a/src/commands/status.update.test.ts +++ b/src/commands/status.update.test.ts @@ -55,6 +55,50 @@ describe("resolveUpdateAvailability", () => { }); }); + it("reports a stale build when dist was built from a different commit", () => { + const update = buildUpdate({ + installKind: "git", + git: { + root: "/tmp/repo", + sha: "abc123456789", + tag: null, + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 0, + fetchOk: true, + builtSha: "def987654321", + }, + }); + + // Pulling without rebuilding keeps the old dist running, which is invisible + // from HEAD alone and is exactly what a failed update leaves behind. + expect(formatUpdateOneLiner(update)).toContain( + "stale build (running def98765, run pnpm build)", + ); + }); + + it("stays quiet when the built commit matches HEAD", () => { + const update = buildUpdate({ + installKind: "git", + git: { + root: "/tmp/repo", + sha: "abc123456789", + tag: null, + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 0, + fetchOk: true, + builtSha: "abc123456789", + }, + }); + + expect(formatUpdateOneLiner(update)).not.toContain("stale build"); + }); + it("flags registry update when latest version is newer", () => { const latestVersion = nextMajorVersion(VERSION); const update = buildUpdate({ diff --git a/src/commands/status.update.ts b/src/commands/status.update.ts index a2f5bd547441..a38714acbb95 100644 --- a/src/commands/status.update.ts +++ b/src/commands/status.update.ts @@ -199,6 +199,11 @@ export function formatUpdateOneLiner(update: UpdateCheckResult): string { if (update.git.fetchOk === false) { parts.push("fetch failed"); } + // A checkout that pulled but never rebuilt keeps executing the previous dist, + // so report the built commit rather than letting HEAD imply what is running. + if (update.git.builtSha && update.git.sha && update.git.builtSha !== update.git.sha) { + parts.push(`stale build (running ${update.git.builtSha.slice(0, 8)}, run pnpm build)`); + } appendRegistryUpdateSummary(); } else { parts.push(update.packageManager !== "unknown" ? update.packageManager : "pkg"); diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index bdaf2f14255d..19c2977276d5 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -21,6 +21,7 @@ import { fetchNpmPackageTargetStatus, type NpmMetadataCommandRunner, } from "./update-check-package-target.js"; +import { readBuiltRuntimeCommit } from "./update-git-runtime.js"; import { updateInstallRootsMatch } from "./update-install-root.js"; import type { UpdateFetchFailure } from "./update-run-record.js"; @@ -40,6 +41,7 @@ type GitUpdateStatus = { ahead: number | null; behind: number | null; fetchOk: boolean | null; + builtSha?: string | null; countsCached?: true; stale?: UpdateFetchFailure; error?: string; @@ -430,6 +432,7 @@ async function checkGitUpdateStatus(params: { ahead: parsed ? Number(parsed[1]) : null, behind: parsed ? Number(parsed[2]) : null, fetchOk, + builtSha: await readBuiltRuntimeCommit(root), }; } diff --git a/src/infra/update-git-runtime.test.ts b/src/infra/update-git-runtime.test.ts new file mode 100644 index 000000000000..b6feffb0e451 --- /dev/null +++ b/src/infra/update-git-runtime.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readBuiltRuntimeCommit } from "./update-git-runtime.js"; + +const roots: string[] = []; + +async function createRoot(): Promise { + // Resolve the temp root: macOS reports /var while prod resolvers return /private/var. + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-built-commit-")), + ); + roots.push(root); + return root; +} + +afterEach(async () => { + while (roots.length > 0) { + const root = roots.pop(); + if (root) { + await fs.rm(root, { recursive: true, force: true }); + } + } +}); + +describe("readBuiltRuntimeCommit", () => { + it("reads the commit the dist was built from", async () => { + const root = await createRoot(); + await fs.mkdir(path.join(root, "dist"), { recursive: true }); + await fs.writeFile( + path.join(root, "dist", "build-info.json"), + JSON.stringify({ version: "2026.8.1", commit: "1623683f478b1e4a2e3d5632585f006ea142e08c" }), + ); + + expect(await readBuiltRuntimeCommit(root)).toBe("1623683f478b1e4a2e3d5632585f006ea142e08c"); + }); + + it("returns null when the checkout has no build", async () => { + // Source-only checkouts must not be reported as running a stale build. + expect(await readBuiltRuntimeCommit(await createRoot())).toBeNull(); + }); + + it("returns null when build provenance omits the commit", async () => { + const root = await createRoot(); + await fs.mkdir(path.join(root, "dist"), { recursive: true }); + await fs.writeFile( + path.join(root, "dist", "build-info.json"), + JSON.stringify({ version: "2026.8.1" }), + ); + + expect(await readBuiltRuntimeCommit(root)).toBeNull(); + }); +}); diff --git a/src/infra/update-git-runtime.ts b/src/infra/update-git-runtime.ts index 95393873cc53..086ef91c631f 100644 --- a/src/infra/update-git-runtime.ts +++ b/src/infra/update-git-runtime.ts @@ -42,6 +42,16 @@ export async function collectGitRuntimeErrors(params: GitRuntimeIdentity): Promi ]; } +/** + * Commit the checkout's dist was built from, or null when no build exists. + * Comparing it to HEAD is how callers detect a checkout that pulled but never + * rebuilt, which otherwise runs old code while reporting the new source version. + */ +export async function readBuiltRuntimeCommit(root: string): Promise { + const buildInfo = await tryReadJson(path.join(root, "dist", "build-info.json")); + return normalizeNullableString(asNullableRecord(buildInfo)?.commit); +} + export async function readBuiltGatewayBuildId(root: string): Promise { const buildInfo = await tryReadJson(path.join(root, "dist", "build-info.json")); const buildId = normalizeNullableString(asNullableRecord(buildInfo)?.buildId); diff --git a/src/version.test.ts b/src/version.test.ts index 38588c610414..e45ef1acca04 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -83,6 +83,18 @@ describe("version resolution", () => { }); }); + it("reports the built version when dist lags the source package version", async () => { + await withVersionFixtureDir(async (root) => { + await writeJsonFixture(root, "package.json", { name: "openclaw", version: "2026.9.2" }); + await writeJsonFixture(root, "build-info.json", { version: "2026.8.1" }); + const moduleUrl = await ensureModuleFixture(root); + // A git checkout that pulled but never rebuilt still executes the old dist, + // so reporting the source version hides the stale runtime from operators. + expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBe("2026.9.2"); + expect(resolveVersionFromModuleUrl(moduleUrl)).toBe("2026.8.1"); + }); + }); + it("reads the bounded immutable build id from generated provenance", async () => { await withVersionFixtureDir(async (root) => { const moduleUrl = await ensureModuleFixture(root); diff --git a/src/version.ts b/src/version.ts index 258000eb8f3e..ee76f1591e31 100644 --- a/src/version.ts +++ b/src/version.ts @@ -91,9 +91,12 @@ export function readBuildIdFromBuildInfoForModuleUrl(moduleUrl: string): string } export function resolveVersionFromModuleUrl(moduleUrl: string): string | null { + // build-info.json records the version this artifact was built from, so it wins: + // a git checkout whose source moved ahead of dist must not report the unbuilt + // source version. Source runs have no build-info and fall back to package.json. return ( - readVersionFromPackageJsonForModuleUrl(moduleUrl) || - readVersionFromBuildInfoForModuleUrl(moduleUrl) + readVersionFromBuildInfoForModuleUrl(moduleUrl) || + readVersionFromPackageJsonForModuleUrl(moduleUrl) ); } diff --git a/test/openclaw-launcher-version.e2e.test.ts b/test/openclaw-launcher-version.e2e.test.ts index 92e3ab2f60f2..1c885a581643 100644 --- a/test/openclaw-launcher-version.e2e.test.ts +++ b/test/openclaw-launcher-version.e2e.test.ts @@ -11,6 +11,7 @@ const buildCommit = "1234567890abcdef1234567890abcdef12345678"; type LauncherVersionFixtureOptions = { buildCommit?: string; + buildVersion?: string; checkout?: "directory" | "linked"; packageCommit?: string; pendingLifecycle?: "complete" | "fail"; @@ -68,7 +69,10 @@ async function makeLauncherVersionFixture( if (options.buildCommit) { await fs.writeFile( path.join(fixtureRoot, "dist", "build-info.json"), - JSON.stringify({ version: packageVersion, commit: options.buildCommit }), + JSON.stringify({ + version: options.buildVersion ?? packageVersion, + commit: options.buildCommit, + }), ); } @@ -144,6 +148,21 @@ describe("openclaw launcher version provenance", () => { }, ); + it("reports the built version when the source package version moved ahead", async () => { + const fixtureRoot = await makeLauncherVersionFixture(fixtureRoots, { + buildCommit, + buildVersion: "2026.8.1", + }); + + // The launcher answers bare --version before the runtime entry loads, so a + // checkout that pulled without rebuilding must not report the unbuilt version. + const result = runLauncherVersion(fixtureRoot); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(`OpenClaw 2026.8.1 (${buildCommit.slice(0, 7)})\n`); + expect(result.stderr).toBe(""); + }); + it.each(["--version", "-V", "-v"])( "reports the packaged build for the %s fast path without importing the runtime", async (flag) => {