feat(update): add core auto-updater and dry-run preview

This commit is contained in:
Peter Steinberger
2026-02-22 17:11:24 +01:00
parent 13690d406a
commit f442a3539f
15 changed files with 673 additions and 45 deletions

View File

@@ -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.

View File

@@ -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 <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version>`: 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 <seconds>`: 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:

View File

@@ -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

View File

@@ -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();

View File

@@ -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 <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option("--timeout <seconds>", "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,

View File

@@ -23,6 +23,7 @@ import { pathExists } from "../../utils.js";
export type UpdateCommandOptions = {
json?: boolean;
restart?: boolean;
dryRun?: boolean;
channel?: string;
tag?: string;
timeout?: string;

View File

@@ -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<void> {
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<void> {
});
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."),
);

View File

@@ -5,6 +5,12 @@ export const FIELD_HELP: Record<string, string> = {
"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).",

View File

@@ -5,6 +5,10 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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?: {

View File

@@ -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(),

View File

@@ -15,6 +15,7 @@ export function createGatewayCloseHandler(params: {
pluginServices: PluginServicesHandle | null;
cron: { stop: () => void };
heartbeatRunner: HeartbeatRunner;
updateCheckStop?: (() => void) | null;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
tickInterval: ReturnType<typeof setInterval>;
@@ -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);
}

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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<typeof loadConfig>): 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<typeof loadConfig>): 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<UpdateCheckState> {
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<AutoUpdateRunResult> {
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<typeof loadConfig>;
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
isNixMode: boolean;
allowInTests?: boolean;
onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void;
runAutoUpdate?: (params: {
channel: "stable" | "beta";
timeoutMs: number;
}) => Promise<AutoUpdateRunResult>;
}): Promise<void> {
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<string, unknown>) => void };
isNixMode: boolean;
onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void;
}): void {
void runGatewayUpdateCheck(params).catch(() => {});
}): () => void {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | 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;
}
};
}