From f442a3539f19348ed902eb7c84a6d00e05672775 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 17:11:24 +0100 Subject: [PATCH] feat(update): add core auto-updater and dry-run preview --- CHANGELOG.md | 2 + docs/cli/update.md | 4 + docs/install/updating.md | 26 +++ src/cli/update-cli.test.ts | 27 +++ src/cli/update-cli.ts | 4 + src/cli/update-cli/shared.ts | 1 + src/cli/update-cli/update-command.ts | 188 +++++++++++++++--- src/config/schema.help.ts | 6 + src/config/schema.labels.ts | 4 + src/config/types.openclaw.ts | 11 ++ src/config/zod-schema.ts | 9 + src/gateway/server-close.ts | 6 + src/gateway/server.impl.ts | 23 +-- src/infra/update-startup.test.ts | 134 ++++++++++++- src/infra/update-startup.ts | 273 ++++++++++++++++++++++++++- 15 files changed, 673 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 240598821..54ae7a775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. +- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting. - Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead. - Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz. - Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path. diff --git a/docs/cli/update.md b/docs/cli/update.md index 5dfd97f9a..7a1840096 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -21,6 +21,7 @@ openclaw update wizard openclaw update --channel beta openclaw update --channel dev openclaw update --tag beta +openclaw update --dry-run openclaw update --no-restart openclaw update --json openclaw --update @@ -31,6 +32,7 @@ openclaw --update - `--no-restart`: skip restarting the Gateway service after a successful update. - `--channel `: set the update channel (git + npm; persisted in config). - `--tag `: override the npm dist-tag or version for this update only. +- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting. - `--json`: print machine-readable `UpdateRunResult` JSON. - `--timeout `: per-step timeout (default is 1200s). @@ -66,6 +68,8 @@ install method aligned: updates it, and installs the global CLI from that checkout. - `stable`/`beta` → installs from npm using the matching dist-tag. +The Gateway core auto-updater (when enabled via config) reuses this same update path. + ## Git checkout flow Channels: diff --git a/docs/install/updating.md b/docs/install/updating.md index e463a5001..6606a933b 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -71,6 +71,32 @@ See [Development channels](/install/development-channels) for channel semantics Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`. +### Core auto-updater (optional) + +Auto-updater is **off by default** and is a core Gateway feature (not a plugin). + +```json +{ + "update": { + "channel": "stable", + "auto": { + "enabled": true, + "stableDelayHours": 6, + "stableJitterHours": 12, + "betaCheckIntervalHours": 1 + } + } +} +``` + +Behavior: + +- `stable`: when a new version is seen, OpenClaw waits `stableDelayHours` and then applies a deterministic per-install jitter in `stableJitterHours` (spread rollout). +- `beta`: checks on `betaCheckIntervalHours` cadence (default: hourly) and applies when an update is available. +- `dev`: no automatic apply; use manual `openclaw update`. + +Use `openclaw update --dry-run` to preview update actions before enabling automation. + Then: ```bash diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 9cd57b78b..fe158fbb5 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -374,6 +374,23 @@ describe("update-cli", () => { expect(defaultRuntime.log).toHaveBeenCalled(); }); + it("updateCommand --dry-run previews without mutating", async () => { + vi.mocked(defaultRuntime.log).mockClear(); + serviceLoaded.mockResolvedValue(true); + + await updateCommand({ dryRun: true, channel: "beta" }); + + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); + + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.join("\n")).toContain("Update dry-run"); + expect(logs.join("\n")).toContain("No changes were applied."); + }); + it("updateStatusCommand prints table output", async () => { await updateStatusCommand({ json: false }); @@ -704,6 +721,16 @@ describe("update-cli", () => { expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate); }); + it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => { + await setupNonInteractiveDowngrade(); + vi.mocked(defaultRuntime.exit).mockClear(); + + await updateCommand({ dryRun: true }); + + expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + }); + it("updateWizardCommand requires a TTY", async () => { setTty(false); vi.mocked(defaultRuntime.error).mockClear(); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 0a7dc5dcd..7f82f701c 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -37,6 +37,7 @@ export function registerUpdateCli(program: Command) { .description("Update OpenClaw and inspect update channel status") .option("--json", "Output result as JSON", false) .option("--no-restart", "Skip restarting the gateway service after a successful update") + .option("--dry-run", "Preview update actions without making changes", false) .option("--channel ", "Persist update channel (git + npm)") .option("--tag ", "Override npm dist-tag or version for this update") .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") @@ -47,6 +48,7 @@ export function registerUpdateCli(program: Command) { ["openclaw update --channel beta", "Switch to beta channel (git + npm)"], ["openclaw update --channel dev", "Switch to dev channel (git + npm)"], ["openclaw update --tag beta", "One-off update to a dist-tag or version"], + ["openclaw update --dry-run", "Preview actions without changing anything"], ["openclaw update --no-restart", "Update without restarting the service"], ["openclaw update --json", "Output result as JSON"], ["openclaw update --yes", "Non-interactive (accept downgrade prompts)"], @@ -69,6 +71,7 @@ ${theme.heading("Switch channels:")} ${theme.heading("Non-interactive:")} - Use --yes to accept downgrade prompts - Combine with --channel/--tag/--restart/--json/--timeout as needed + - Use --dry-run to preview actions without writing config/installing/restarting ${theme.heading("Examples:")} ${fmtExamples} @@ -86,6 +89,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up await updateCommand({ json: Boolean(opts.json), restart: Boolean(opts.restart), + dryRun: Boolean(opts.dryRun), channel: opts.channel as string | undefined, tag: opts.tag as string | undefined, timeout: opts.timeout as string | undefined, diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 2cf53e201..50e1fd094 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -23,6 +23,7 @@ import { pathExists } from "../../utils.js"; export type UpdateCommandOptions = { json?: boolean; restart?: boolean; + dryRun?: boolean; channel?: string; tag?: string; timeout?: string; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 58536704d..3c672a02d 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -114,6 +114,65 @@ function formatCommandFailure(stdout: string, stderr: string): string { return detail.split("\n").slice(-3).join("\n"); } +type UpdateDryRunPreview = { + dryRun: true; + root: string; + installKind: "git" | "package" | "unknown"; + mode: UpdateRunResult["mode"]; + updateInstallKind: "git" | "package" | "unknown"; + switchToGit: boolean; + switchToPackage: boolean; + restart: boolean; + requestedChannel: "stable" | "beta" | "dev" | null; + storedChannel: "stable" | "beta" | "dev" | null; + effectiveChannel: "stable" | "beta" | "dev"; + tag: string; + currentVersion: string | null; + targetVersion: string | null; + downgradeRisk: boolean; + actions: string[]; + notes: string[]; +}; + +function printDryRunPreview(preview: UpdateDryRunPreview, jsonMode: boolean): void { + if (jsonMode) { + defaultRuntime.log(JSON.stringify(preview, null, 2)); + return; + } + + defaultRuntime.log(theme.heading("Update dry-run")); + defaultRuntime.log(theme.muted("No changes were applied.")); + defaultRuntime.log(""); + defaultRuntime.log(` Root: ${theme.muted(preview.root)}`); + defaultRuntime.log(` Install kind: ${theme.muted(preview.installKind)}`); + defaultRuntime.log(` Mode: ${theme.muted(preview.mode)}`); + defaultRuntime.log(` Channel: ${theme.muted(preview.effectiveChannel)}`); + defaultRuntime.log(` Tag/spec: ${theme.muted(preview.tag)}`); + if (preview.currentVersion) { + defaultRuntime.log(` Current version: ${theme.muted(preview.currentVersion)}`); + } + if (preview.targetVersion) { + defaultRuntime.log(` Target version: ${theme.muted(preview.targetVersion)}`); + } + if (preview.downgradeRisk) { + defaultRuntime.log(theme.warn(" Downgrade confirmation would be required in a real run.")); + } + + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Planned actions:")); + for (const action of preview.actions) { + defaultRuntime.log(` - ${action}`); + } + + if (preview.notes.length > 0) { + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Notes:")); + for (const note of preview.notes) { + defaultRuntime.log(` - ${theme.muted(note)}`); + } + } +} + async function refreshGatewayServiceEnv(params: { result: UpdateRunResult; jsonMode: boolean; @@ -613,11 +672,14 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const explicitTag = normalizeTag(opts.tag); let tag = explicitTag ?? channelToNpmTag(channel); + let currentVersion: string | null = null; + let targetVersion: string | null = null; + let downgradeRisk = false; + let fallbackToLatest = false; if (updateInstallKind !== "git") { - const currentVersion = switchToPackage ? null : await readPackageVersion(root); - let fallbackToLatest = false; - const targetVersion = explicitTag + currentVersion = switchToPackage ? null : await readPackageVersion(root); + targetVersion = explicitTag ? await resolveTargetVersion(tag, timeoutMs) : await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => { tag = resolved.tag; @@ -626,38 +688,106 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { }); const cmp = currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null; - const needsConfirm = + downgradeRisk = !fallbackToLatest && currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0)); + } - if (needsConfirm && !opts.yes) { - if (!process.stdin.isTTY || opts.json) { - defaultRuntime.error( - [ - "Downgrade confirmation required.", - "Downgrading can break configuration. Re-run in a TTY to confirm.", - ].join("\n"), - ); - defaultRuntime.exit(1); - return; - } - - const targetLabel = targetVersion ?? `${tag} (unknown)`; - const message = `Downgrading from ${currentVersion} to ${targetLabel} can break configuration. Continue?`; - const ok = await confirm({ - message: stylePromptMessage(message), - initialValue: false, + if (opts.dryRun) { + let mode: UpdateRunResult["mode"] = "unknown"; + if (updateInstallKind === "git") { + mode = "git"; + } else if (updateInstallKind === "package") { + mode = await resolveGlobalManager({ + root, + installKind, + timeoutMs: timeoutMs ?? 20 * 60_000, }); - if (isCancel(ok) || !ok) { - if (!opts.json) { - defaultRuntime.log(theme.muted("Update cancelled.")); - } - defaultRuntime.exit(0); - return; - } } - } else if (opts.tag && !opts.json) { + + const actions: string[] = []; + if (requestedChannel && requestedChannel !== storedChannel) { + actions.push(`Persist update.channel=${requestedChannel} in config`); + } + if (switchToGit) { + actions.push("Switch install mode from package to git checkout (dev channel)"); + } else if (switchToPackage) { + actions.push(`Switch install mode from git to package manager (${mode})`); + } else if (updateInstallKind === "git") { + actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`); + } else { + actions.push(`Run global package manager update with spec openclaw@${tag}`); + } + actions.push("Run plugin update sync after core update"); + actions.push("Refresh shell completion cache (if needed)"); + actions.push( + shouldRestart + ? "Restart gateway service and run doctor checks" + : "Skip restart (because --no-restart is set)", + ); + + const notes: string[] = []; + if (opts.tag && updateInstallKind === "git") { + notes.push("--tag applies to npm installs only; git updates ignore it."); + } + if (fallbackToLatest) { + notes.push("Beta channel resolves to latest for this run (fallback)."); + } + + printDryRunPreview( + { + dryRun: true, + root, + installKind, + mode, + updateInstallKind, + switchToGit, + switchToPackage, + restart: shouldRestart, + requestedChannel, + storedChannel, + effectiveChannel: channel, + tag, + currentVersion, + targetVersion, + downgradeRisk, + actions, + notes, + }, + Boolean(opts.json), + ); + return; + } + + if (downgradeRisk && !opts.yes) { + if (!process.stdin.isTTY || opts.json) { + defaultRuntime.error( + [ + "Downgrade confirmation required.", + "Downgrading can break configuration. Re-run in a TTY to confirm.", + ].join("\n"), + ); + defaultRuntime.exit(1); + return; + } + + const targetLabel = targetVersion ?? `${tag} (unknown)`; + const message = `Downgrading from ${currentVersion} to ${targetLabel} can break configuration. Continue?`; + const ok = await confirm({ + message: stylePromptMessage(message), + initialValue: false, + }); + if (isCancel(ok) || !ok) { + if (!opts.json) { + defaultRuntime.log(theme.muted("Update cancelled.")); + } + defaultRuntime.exit(0); + return; + } + } + + if (updateInstallKind === "git" && opts.tag && !opts.json) { defaultRuntime.log( theme.muted("Note: --tag applies to npm installs only; git updates ignore it."), ); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 18c53b5f9..1dc98be1d 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -5,6 +5,12 @@ export const FIELD_HELP: Record = { "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").', "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", + "update.auto.enabled": "Enable background auto-update for package installs (default: false).", + "update.auto.stableDelayHours": + "Minimum delay before stable-channel auto-apply starts (default: 6).", + "update.auto.stableJitterHours": + "Extra stable-channel rollout spread window in hours (default: 12).", + "update.auto.betaCheckIntervalHours": "How often beta-channel checks run in hours (default: 1).", "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", "gateway.remote.tlsFingerprint": "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 0563341dc..ba2b2691a 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -5,6 +5,10 @@ export const FIELD_LABELS: Record = { "meta.lastTouchedAt": "Config Last Touched At", "update.channel": "Update Channel", "update.checkOnStart": "Update Check on Start", + "update.auto.enabled": "Auto Update Enabled", + "update.auto.stableDelayHours": "Auto Update Stable Delay (hours)", + "update.auto.stableJitterHours": "Auto Update Stable Jitter (hours)", + "update.auto.betaCheckIntervalHours": "Auto Update Beta Check Interval (hours)", "diagnostics.enabled": "Diagnostics Enabled", "diagnostics.flags": "Diagnostics Flags", "diagnostics.otel.enabled": "OpenTelemetry Enabled", diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index a3ca92c7b..5b6b22402 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -63,6 +63,17 @@ export type OpenClawConfig = { channel?: "stable" | "beta" | "dev"; /** Check for updates on gateway start (npm installs only). */ checkOnStart?: boolean; + /** Core auto-update policy for package installs. */ + auto?: { + /** Enable background auto-update checks and apply logic. Default: false. */ + enabled?: boolean; + /** Stable channel minimum delay before auto-apply. Default: 6. */ + stableDelayHours?: number; + /** Additional stable-channel jitter window. Default: 12. */ + stableJitterHours?: number; + /** Beta channel check cadence. Default: 1 hour. */ + betaCheckIntervalHours?: number; + }; }; browser?: BrowserConfig; ui?: { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index cf4d67c9d..c0efc811a 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -213,6 +213,15 @@ export const OpenClawSchema = z .object({ channel: z.union([z.literal("stable"), z.literal("beta"), z.literal("dev")]).optional(), checkOnStart: z.boolean().optional(), + auto: z + .object({ + enabled: z.boolean().optional(), + stableDelayHours: z.number().nonnegative().max(168).optional(), + stableJitterHours: z.number().nonnegative().max(168).optional(), + betaCheckIntervalHours: z.number().positive().max(24).optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index da9f5a39e..635f830b5 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -15,6 +15,7 @@ export function createGatewayCloseHandler(params: { pluginServices: PluginServicesHandle | null; cron: { stop: () => void }; heartbeatRunner: HeartbeatRunner; + updateCheckStop?: (() => void) | null; nodePresenceTimers: Map>; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; tickInterval: ReturnType; @@ -70,6 +71,11 @@ export function createGatewayCloseHandler(params: { await stopGmailWatcher(); params.cron.stop(); params.heartbeatRunner.stop(); + try { + params.updateCheckStop?.(); + } catch { + /* ignore */ + } for (const timer of params.nodePresenceTimers.values()) { clearInterval(timer); } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 622441831..2668e394a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -632,17 +632,17 @@ export async function startGatewayServer( log, isNixMode, }); - if (!minimalTestGateway) { - scheduleGatewayUpdateCheck({ - cfg: cfgAtStart, - log, - isNixMode, - onUpdateAvailableChange: (updateAvailable) => { - const payload: GatewayUpdateAvailableEventPayload = { updateAvailable }; - broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true }); - }, - }); - } + const stopGatewayUpdateCheck = minimalTestGateway + ? () => {} + : scheduleGatewayUpdateCheck({ + cfg: cfgAtStart, + log, + isNixMode, + onUpdateAvailableChange: (updateAvailable) => { + const payload: GatewayUpdateAvailableEventPayload = { updateAvailable }; + broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true }); + }, + }); const tailscaleCleanup = minimalTestGateway ? null : await startGatewayTailscaleExposure({ @@ -730,6 +730,7 @@ export async function startGatewayServer( pluginServices, cron, heartbeatRunner, + updateCheckStop: stopGatewayUpdateCheck, nodePresenceTimers, broadcast, tickInterval, diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 9d1f14cac..fc6575468 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -35,6 +35,10 @@ vi.mock("../version.js", () => ({ VERSION: "1.0.0", })); +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: vi.fn(), +})); + describe("update-startup", () => { let suiteRoot = ""; let suiteCase = 0; @@ -45,6 +49,7 @@ describe("update-startup", () => { let checkUpdateStatus: (typeof import("./update-check.js"))["checkUpdateStatus"]; let resolveNpmChannelTag: (typeof import("./update-check.js"))["resolveNpmChannelTag"]; let runGatewayUpdateCheck: (typeof import("./update-startup.js"))["runGatewayUpdateCheck"]; + let scheduleGatewayUpdateCheck: (typeof import("./update-startup.js"))["scheduleGatewayUpdateCheck"]; let getUpdateAvailable: (typeof import("./update-startup.js"))["getUpdateAvailable"]; let resetUpdateAvailableStateForTest: (typeof import("./update-startup.js"))["resetUpdateAvailableStateForTest"]; let loaded = false; @@ -70,8 +75,12 @@ describe("update-startup", () => { if (!loaded) { ({ resolveOpenClawPackageRoot } = await import("./openclaw-root.js")); ({ checkUpdateStatus, resolveNpmChannelTag } = await import("./update-check.js")); - ({ runGatewayUpdateCheck, getUpdateAvailable, resetUpdateAvailableStateForTest } = - await import("./update-startup.js")); + ({ + runGatewayUpdateCheck, + scheduleGatewayUpdateCheck, + getUpdateAvailable, + resetUpdateAvailableStateForTest, + } = await import("./update-startup.js")); loaded = true; } vi.mocked(resolveOpenClawPackageRoot).mockClear(); @@ -238,4 +247,125 @@ describe("update-startup", () => { expect(log.info).not.toHaveBeenCalled(); await expect(fs.stat(path.join(tempDir, "update-check.json"))).rejects.toThrow(); }); + + it("defers stable auto-update until rollout window is due", 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: "latest", + version: "2.0.0", + }); + + const runAutoUpdate = vi.fn().mockResolvedValue({ + ok: true, + code: 0, + }); + + await runGatewayUpdateCheck({ + cfg: { + update: { + channel: "stable", + auto: { + enabled: true, + stableDelayHours: 6, + stableJitterHours: 12, + }, + }, + }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + runAutoUpdate, + }); + expect(runAutoUpdate).not.toHaveBeenCalled(); + + vi.setSystemTime(new Date("2026-01-18T07:00:00Z")); + await runGatewayUpdateCheck({ + cfg: { + update: { + channel: "stable", + auto: { + enabled: true, + stableDelayHours: 6, + stableJitterHours: 12, + }, + }, + }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + runAutoUpdate, + }); + + expect(runAutoUpdate).toHaveBeenCalledTimes(1); + expect(runAutoUpdate).toHaveBeenCalledWith({ + channel: "stable", + timeoutMs: 45 * 60 * 1000, + }); + }); + + it("runs beta auto-update checks hourly when 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: { + channel: "beta", + auto: { + enabled: true, + betaCheckIntervalHours: 1, + }, + }, + }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + runAutoUpdate, + }); + + expect(runAutoUpdate).toHaveBeenCalledTimes(1); + expect(runAutoUpdate).toHaveBeenCalledWith({ + channel: "beta", + timeoutMs: 45 * 60 * 1000, + }); + }); + + it("scheduleGatewayUpdateCheck returns a cleanup function", 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: "latest", + version: "2.0.0", + }); + + const stop = scheduleGatewayUpdateCheck({ + cfg: { update: { channel: "stable" } }, + log: { info: vi.fn() }, + isNixMode: false, + }); + expect(typeof stop).toBe("function"); + stop(); + }); }); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 63f68f772..751eb5f37 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -1,8 +1,10 @@ +import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { formatCliCommand } from "../cli/command-format.js"; import type { loadConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import { VERSION } from "../version.js"; import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; import { normalizeUpdateChannel, DEFAULT_PACKAGE_CHANNEL } from "./update-channels.js"; @@ -14,6 +16,29 @@ type UpdateCheckState = { lastNotifiedTag?: string; lastAvailableVersion?: string; lastAvailableTag?: string; + autoInstallId?: string; + autoFirstSeenVersion?: string; + autoFirstSeenTag?: string; + autoFirstSeenAt?: string; + autoLastAttemptVersion?: string; + autoLastAttemptAt?: string; + autoLastSuccessVersion?: string; + autoLastSuccessAt?: string; +}; + +type AutoUpdatePolicy = { + enabled: boolean; + stableDelayHours: number; + stableJitterHours: number; + betaCheckIntervalHours: number; +}; + +type AutoUpdateRunResult = { + ok: boolean; + code: number | null; + stdout?: string; + stderr?: string; + reason?: string; }; export type UpdateAvailable = { @@ -34,6 +59,11 @@ export function resetUpdateAvailableStateForTest(): void { const UPDATE_CHECK_FILENAME = "update-check.json"; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const ONE_HOUR_MS = 60 * 60 * 1000; +const AUTO_UPDATE_COMMAND_TIMEOUT_MS = 45 * 60 * 1000; +const AUTO_STABLE_DELAY_HOURS_DEFAULT = 6; +const AUTO_STABLE_JITTER_HOURS_DEFAULT = 12; +const AUTO_BETA_CHECK_INTERVAL_HOURS_DEFAULT = 1; function shouldSkipCheck(allowInTests: boolean): boolean { if (allowInTests) { @@ -45,6 +75,44 @@ function shouldSkipCheck(allowInTests: boolean): boolean { return false; } +function resolveAutoUpdatePolicy(cfg: ReturnType): AutoUpdatePolicy { + const auto = cfg.update?.auto; + const stableDelayHours = + typeof auto?.stableDelayHours === "number" && Number.isFinite(auto.stableDelayHours) + ? Math.max(0, auto.stableDelayHours) + : AUTO_STABLE_DELAY_HOURS_DEFAULT; + const stableJitterHours = + typeof auto?.stableJitterHours === "number" && Number.isFinite(auto.stableJitterHours) + ? Math.max(0, auto.stableJitterHours) + : AUTO_STABLE_JITTER_HOURS_DEFAULT; + const betaCheckIntervalHours = + typeof auto?.betaCheckIntervalHours === "number" && Number.isFinite(auto.betaCheckIntervalHours) + ? Math.max(0.25, auto.betaCheckIntervalHours) + : AUTO_BETA_CHECK_INTERVAL_HOURS_DEFAULT; + + return { + enabled: Boolean(auto?.enabled), + stableDelayHours, + stableJitterHours, + betaCheckIntervalHours, + }; +} + +function resolveCheckIntervalMs(cfg: ReturnType): number { + const channel = normalizeUpdateChannel(cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; + const auto = resolveAutoUpdatePolicy(cfg); + if (!auto.enabled) { + return UPDATE_CHECK_INTERVAL_MS; + } + if (channel === "beta") { + return Math.max(ONE_HOUR_MS / 4, Math.floor(auto.betaCheckIntervalHours * ONE_HOUR_MS)); + } + if (channel === "stable") { + return ONE_HOUR_MS; + } + return UPDATE_CHECK_INTERVAL_MS; +} + async function readState(statePath: string): Promise { try { const raw = await fs.readFile(statePath, "utf-8"); @@ -102,12 +170,110 @@ function resolvePersistedUpdateAvailable(state: UpdateCheckState): UpdateAvailab }; } +function resolveStableJitterMs(params: { + installId: string; + version: string; + tag: string; + jitterWindowMs: number; +}): number { + if (params.jitterWindowMs <= 0) { + return 0; + } + const hash = createHash("sha256") + .update(`${params.installId}:${params.version}:${params.tag}`) + .digest(); + const bucket = hash.readUInt32BE(0); + return bucket % (Math.floor(params.jitterWindowMs) + 1); +} + +function resolveStableAutoApplyAtMs(params: { + state: UpdateCheckState; + nextState: UpdateCheckState; + nowMs: number; + version: string; + tag: string; + stableDelayHours: number; + stableJitterHours: number; +}): number { + if (!params.nextState.autoInstallId) { + params.nextState.autoInstallId = params.state.autoInstallId?.trim() || randomUUID(); + } + const installId = params.nextState.autoInstallId; + const matchesExisting = + params.state.autoFirstSeenVersion === params.version && + params.state.autoFirstSeenTag === params.tag; + + if (!matchesExisting) { + params.nextState.autoFirstSeenVersion = params.version; + params.nextState.autoFirstSeenTag = params.tag; + params.nextState.autoFirstSeenAt = new Date(params.nowMs).toISOString(); + } else { + params.nextState.autoFirstSeenVersion = params.state.autoFirstSeenVersion; + params.nextState.autoFirstSeenTag = params.state.autoFirstSeenTag; + params.nextState.autoFirstSeenAt = params.state.autoFirstSeenAt; + } + + const firstSeenMs = params.nextState.autoFirstSeenAt + ? Date.parse(params.nextState.autoFirstSeenAt) + : params.nowMs; + const baseDelayMs = Math.max(0, params.stableDelayHours) * ONE_HOUR_MS; + const jitterWindowMs = Math.max(0, params.stableJitterHours) * ONE_HOUR_MS; + const jitterMs = resolveStableJitterMs({ + installId, + version: params.version, + tag: params.tag, + jitterWindowMs, + }); + + return firstSeenMs + baseDelayMs + jitterMs; +} + +async function runAutoUpdateCommand(params: { + channel: "stable" | "beta"; + timeoutMs: number; +}): Promise { + try { + const res = await runCommandWithTimeout( + ["openclaw", "update", "--yes", "--channel", params.channel, "--json"], + { + timeoutMs: params.timeoutMs, + env: { + OPENCLAW_AUTO_UPDATE: "1", + }, + }, + ); + return { + ok: res.code === 0, + code: res.code, + stdout: res.stdout, + stderr: res.stderr, + reason: res.code === 0 ? undefined : "non-zero-exit", + }; + } catch (err) { + return { + ok: false, + code: null, + reason: String(err), + }; + } +} + +function clearAutoState(nextState: UpdateCheckState): void { + delete nextState.autoFirstSeenVersion; + delete nextState.autoFirstSeenTag; + delete nextState.autoFirstSeenAt; +} + export async function runGatewayUpdateCheck(params: { cfg: ReturnType; log: { info: (msg: string, meta?: Record) => void }; isNixMode: boolean; allowInTests?: boolean; onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void; + runAutoUpdate?: (params: { + channel: "stable" | "beta"; + timeoutMs: number; + }) => Promise; }): Promise { if (shouldSkipCheck(Boolean(params.allowInTests))) { return; @@ -128,8 +294,9 @@ export async function runGatewayUpdateCheck(params: { next: persistedAvailable, onUpdateAvailableChange: params.onUpdateAvailableChange, }); + const checkIntervalMs = resolveCheckIntervalMs(params.cfg); if (lastCheckedAt && Number.isFinite(lastCheckedAt)) { - if (now - lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) { + if (now - lastCheckedAt < checkIntervalMs) { return; } } @@ -154,6 +321,7 @@ export async function runGatewayUpdateCheck(params: { if (status.installKind !== "package") { delete nextState.lastAvailableVersion; delete nextState.lastAvailableTag; + clearAutoState(nextState); setUpdateAvailableCache({ next: null, onUpdateAvailableChange: params.onUpdateAvailableChange, @@ -192,9 +360,76 @@ export async function runGatewayUpdateCheck(params: { nextState.lastNotifiedVersion = resolved.version; nextState.lastNotifiedTag = tag; } + + const auto = resolveAutoUpdatePolicy(params.cfg); + if (auto.enabled && (channel === "stable" || channel === "beta")) { + const runAuto = params.runAutoUpdate ?? runAutoUpdateCommand; + const attemptIntervalMs = + channel === "beta" + ? Math.max(ONE_HOUR_MS / 4, Math.floor(auto.betaCheckIntervalHours * ONE_HOUR_MS)) + : ONE_HOUR_MS; + const lastAttemptAt = state.autoLastAttemptAt ? Date.parse(state.autoLastAttemptAt) : null; + const recentAttemptForSameVersion = + state.autoLastAttemptVersion === resolved.version && + lastAttemptAt != null && + Number.isFinite(lastAttemptAt) && + now - lastAttemptAt < attemptIntervalMs; + + let dueNow = channel === "beta"; + let applyAfterMs: number | null = null; + if (channel === "stable") { + applyAfterMs = resolveStableAutoApplyAtMs({ + state, + nextState, + nowMs: now, + version: resolved.version, + tag, + stableDelayHours: auto.stableDelayHours, + stableJitterHours: auto.stableJitterHours, + }); + dueNow = now >= applyAfterMs; + } + + if (!dueNow) { + params.log.info("auto-update deferred (stable rollout window active)", { + version: resolved.version, + tag, + applyAfter: applyAfterMs ? new Date(applyAfterMs).toISOString() : undefined, + }); + } else if (recentAttemptForSameVersion) { + params.log.info("auto-update deferred (recent attempt exists)", { + version: resolved.version, + tag, + }); + } else { + nextState.autoLastAttemptVersion = resolved.version; + nextState.autoLastAttemptAt = new Date(now).toISOString(); + const outcome = await runAuto({ + channel, + timeoutMs: AUTO_UPDATE_COMMAND_TIMEOUT_MS, + }); + if (outcome.ok) { + nextState.autoLastSuccessVersion = resolved.version; + nextState.autoLastSuccessAt = new Date(now).toISOString(); + params.log.info("auto-update applied", { + channel, + version: resolved.version, + tag, + }); + } else { + params.log.info("auto-update attempt failed", { + channel, + version: resolved.version, + tag, + reason: outcome.reason ?? `exit:${outcome.code}`, + }); + } + } + } } else { delete nextState.lastAvailableVersion; delete nextState.lastAvailableTag; + clearAutoState(nextState); setUpdateAvailableCache({ next: null, onUpdateAvailableChange: params.onUpdateAvailableChange, @@ -209,6 +444,38 @@ export function scheduleGatewayUpdateCheck(params: { log: { info: (msg: string, meta?: Record) => void }; isNixMode: boolean; onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void; -}): void { - void runGatewayUpdateCheck(params).catch(() => {}); +}): () => void { + let stopped = false; + let timer: ReturnType | null = null; + let running = false; + + const tick = async () => { + if (stopped || running) { + return; + } + running = true; + try { + await runGatewayUpdateCheck(params); + } catch { + // Intentionally ignored: update checks should never crash the gateway loop. + } finally { + running = false; + } + if (stopped) { + return; + } + const intervalMs = resolveCheckIntervalMs(params.cfg); + timer = setTimeout(() => { + void tick(); + }, intervalMs); + }; + + void tick(); + return () => { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + }; }