From dbc1ed893306db882e56145c42fa05efd9fb74dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 17:40:42 +0100 Subject: [PATCH] fix(update): run auto-update via runtime argv and keep it independent of checkOnStart --- src/infra/update-startup.test.ts | 101 +++++++++++++++++++++++++++++++ src/infra/update-startup.ts | 85 ++++++++++++++++++++------ 2 files changed, 166 insertions(+), 20 deletions(-) diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index fc6575468..c9c03812c 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -48,6 +48,7 @@ describe("update-startup", () => { let resolveOpenClawPackageRoot: (typeof import("./openclaw-root.js"))["resolveOpenClawPackageRoot"]; let checkUpdateStatus: (typeof import("./update-check.js"))["checkUpdateStatus"]; let resolveNpmChannelTag: (typeof import("./update-check.js"))["resolveNpmChannelTag"]; + let runCommandWithTimeout: (typeof import("../process/exec.js"))["runCommandWithTimeout"]; let runGatewayUpdateCheck: (typeof import("./update-startup.js"))["runGatewayUpdateCheck"]; let scheduleGatewayUpdateCheck: (typeof import("./update-startup.js"))["scheduleGatewayUpdateCheck"]; let getUpdateAvailable: (typeof import("./update-startup.js"))["getUpdateAvailable"]; @@ -75,6 +76,7 @@ describe("update-startup", () => { if (!loaded) { ({ resolveOpenClawPackageRoot } = await import("./openclaw-root.js")); ({ checkUpdateStatus, resolveNpmChannelTag } = await import("./update-check.js")); + ({ runCommandWithTimeout } = await import("../process/exec.js")); ({ runGatewayUpdateCheck, scheduleGatewayUpdateCheck, @@ -86,6 +88,7 @@ describe("update-startup", () => { vi.mocked(resolveOpenClawPackageRoot).mockClear(); vi.mocked(checkUpdateStatus).mockClear(); vi.mocked(resolveNpmChannelTag).mockClear(); + vi.mocked(runCommandWithTimeout).mockClear(); resetUpdateAvailableStateForTest(); }); @@ -305,6 +308,7 @@ describe("update-startup", () => { expect(runAutoUpdate).toHaveBeenCalledWith({ channel: "stable", timeoutMs: 45 * 60 * 1000, + root: "/opt/openclaw", }); }); @@ -345,9 +349,106 @@ describe("update-startup", () => { expect(runAutoUpdate).toHaveBeenCalledWith({ channel: "beta", timeoutMs: 45 * 60 * 1000, + root: "/opt/openclaw", }); }); + it("runs auto-update when checkOnStart is false but auto-update is enabled", async () => { + vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw"); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/opt/openclaw", + installKind: "package", + packageManager: "npm", + } satisfies UpdateCheckResult); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "beta", + version: "2.0.0-beta.1", + }); + const runAutoUpdate = vi.fn().mockResolvedValue({ + ok: true, + code: 0, + }); + + await runGatewayUpdateCheck({ + cfg: { + update: { + checkOnStart: false, + channel: "beta", + auto: { + enabled: true, + betaCheckIntervalHours: 1, + }, + }, + }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + runAutoUpdate, + }); + + expect(runAutoUpdate).toHaveBeenCalledTimes(1); + }); + + it("uses current runtime + entrypoint for default auto-update command execution", async () => { + vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw"); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/opt/openclaw", + installKind: "package", + packageManager: "npm", + } satisfies UpdateCheckResult); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "beta", + version: "2.0.0-beta.1", + }); + vi.mocked(runCommandWithTimeout).mockResolvedValue({ + stdout: "{}", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }); + + const originalArgv = process.argv.slice(); + process.argv = [process.execPath, "/opt/openclaw/dist/entry.js"]; + try { + await runGatewayUpdateCheck({ + cfg: { + update: { + channel: "beta", + auto: { + enabled: true, + betaCheckIntervalHours: 1, + }, + }, + }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + }); + } finally { + process.argv = originalArgv; + } + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [ + process.execPath, + "/opt/openclaw/dist/entry.js", + "update", + "--yes", + "--channel", + "beta", + "--json", + ], + expect.objectContaining({ + timeoutMs: 45 * 60 * 1000, + env: expect.objectContaining({ + OPENCLAW_AUTO_UPDATE: "1", + }), + }), + ); + }); + it("scheduleGatewayUpdateCheck returns a cleanup function", async () => { vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw"); vi.mocked(checkUpdateStatus).mockResolvedValue({ diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 751eb5f37..1ca5be21c 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -231,17 +231,50 @@ function resolveStableAutoApplyAtMs(params: { async function runAutoUpdateCommand(params: { channel: "stable" | "beta"; timeoutMs: number; + root?: string; }): Promise { + const baseArgs = ["update", "--yes", "--channel", params.channel, "--json"]; + const execPath = process.execPath?.trim(); + const argv1 = process.argv[1]?.trim(); + const lowerExecBase = execPath ? path.basename(execPath).toLowerCase() : ""; + const runtimeIsNodeOrBun = + lowerExecBase === "node" || + lowerExecBase === "node.exe" || + lowerExecBase === "bun" || + lowerExecBase === "bun.exe"; + const argv: string[] = []; + if (execPath && argv1) { + argv.push(execPath, argv1, ...baseArgs); + } else if (execPath && !runtimeIsNodeOrBun) { + argv.push(execPath, ...baseArgs); + } else if (execPath && params.root) { + const candidates = [ + path.join(params.root, "dist", "entry.js"), + path.join(params.root, "dist", "entry.mjs"), + path.join(params.root, "dist", "index.js"), + path.join(params.root, "dist", "index.mjs"), + ]; + for (const candidate of candidates) { + try { + await fs.access(candidate); + argv.push(execPath, candidate, ...baseArgs); + break; + } catch { + // try next candidate + } + } + } + if (argv.length === 0) { + argv.push("openclaw", ...baseArgs); + } + try { - const res = await runCommandWithTimeout( - ["openclaw", "update", "--yes", "--channel", params.channel, "--json"], - { - timeoutMs: params.timeoutMs, - env: { - OPENCLAW_AUTO_UPDATE: "1", - }, + const res = await runCommandWithTimeout(argv, { + timeoutMs: params.timeoutMs, + env: { + OPENCLAW_AUTO_UPDATE: "1", }, - ); + }); return { ok: res.code === 0, code: res.code, @@ -273,6 +306,7 @@ export async function runGatewayUpdateCheck(params: { runAutoUpdate?: (params: { channel: "stable" | "beta"; timeoutMs: number; + root?: string; }) => Promise; }): Promise { if (shouldSkipCheck(Boolean(params.allowInTests))) { @@ -281,7 +315,9 @@ export async function runGatewayUpdateCheck(params: { if (params.isNixMode) { return; } - if (params.cfg.update?.checkOnStart === false) { + const auto = resolveAutoUpdatePolicy(params.cfg); + const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false; + if (!shouldRunUpdateHints && !auto.enabled) { return; } @@ -289,11 +325,18 @@ export async function runGatewayUpdateCheck(params: { const state = await readState(statePath); const now = Date.now(); const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : null; - const persistedAvailable = resolvePersistedUpdateAvailable(state); - setUpdateAvailableCache({ - next: persistedAvailable, - onUpdateAvailableChange: params.onUpdateAvailableChange, - }); + if (shouldRunUpdateHints) { + const persistedAvailable = resolvePersistedUpdateAvailable(state); + setUpdateAvailableCache({ + next: persistedAvailable, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + } else { + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + } const checkIntervalMs = resolveCheckIntervalMs(params.cfg); if (lastCheckedAt && Number.isFinite(lastCheckedAt)) { if (now - lastCheckedAt < checkIntervalMs) { @@ -345,15 +388,17 @@ export async function runGatewayUpdateCheck(params: { latestVersion: resolved.version, channel: tag, }; - setUpdateAvailableCache({ - next: nextAvailable, - onUpdateAvailableChange: params.onUpdateAvailableChange, - }); + if (shouldRunUpdateHints) { + setUpdateAvailableCache({ + next: nextAvailable, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + } nextState.lastAvailableVersion = resolved.version; nextState.lastAvailableTag = tag; const shouldNotify = state.lastNotifiedVersion !== resolved.version || state.lastNotifiedTag !== tag; - if (shouldNotify) { + if (shouldRunUpdateHints && shouldNotify) { params.log.info( `update available (${tag}): v${resolved.version} (current v${VERSION}). Run: ${formatCliCommand("openclaw update")}`, ); @@ -361,7 +406,6 @@ export async function runGatewayUpdateCheck(params: { nextState.lastNotifiedTag = tag; } - const auto = resolveAutoUpdatePolicy(params.cfg); if (auto.enabled && (channel === "stable" || channel === "beta")) { const runAuto = params.runAutoUpdate ?? runAutoUpdateCommand; const attemptIntervalMs = @@ -407,6 +451,7 @@ export async function runGatewayUpdateCheck(params: { const outcome = await runAuto({ channel, timeoutMs: AUTO_UPDATE_COMMAND_TIMEOUT_MS, + root: root ?? undefined, }); if (outcome.ok) { nextState.autoLastSuccessVersion = resolved.version;