2026-02-22 17:11:24 +01:00
|
|
|
import { createHash, randomUUID } from "node:crypto";
|
2026-01-17 12:07:14 +00:00
|
|
|
import fs from "node:fs/promises";
|
|
|
|
|
import path from "node:path";
|
2026-02-18 01:29:02 +00:00
|
|
|
import { formatCliCommand } from "../cli/command-format.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type { loadConfig } from "../config/config.js";
|
2026-01-17 12:07:14 +00:00
|
|
|
import { resolveStateDir } from "../config/paths.js";
|
2026-02-22 17:11:24 +01:00
|
|
|
import { runCommandWithTimeout } from "../process/exec.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { VERSION } from "../version.js";
|
2026-03-02 16:44:46 +00:00
|
|
|
import { writeJsonAtomic } from "./json-files.js";
|
2026-01-30 03:15:10 +01:00
|
|
|
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
|
2026-01-20 16:28:25 +00:00
|
|
|
import { normalizeUpdateChannel, DEFAULT_PACKAGE_CHANNEL } from "./update-channels.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { compareSemverStrings, resolveNpmChannelTag, checkUpdateStatus } from "./update-check.js";
|
2026-01-17 12:07:14 +00:00
|
|
|
|
|
|
|
|
type UpdateCheckState = {
|
|
|
|
|
lastCheckedAt?: string;
|
|
|
|
|
lastNotifiedVersion?: string;
|
|
|
|
|
lastNotifiedTag?: string;
|
2026-02-19 10:00:27 +01:00
|
|
|
lastAvailableVersion?: string;
|
|
|
|
|
lastAvailableTag?: string;
|
2026-02-22 17:11:24 +01:00
|
|
|
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;
|
2026-01-17 12:07:14 +00:00
|
|
|
};
|
|
|
|
|
|
2026-02-19 10:00:27 +01:00
|
|
|
export type UpdateAvailable = {
|
feat(ui): add update warning banner to control dashboard
SecurityScorecard's STRIKE research recently identified over 40,000
exposed OpenClaw gateway instances, with 35.4% running known-vulnerable
versions. The gateway already performs an npm update check on startup
and compares against the registry every 24 hours — but the result is
only logged to the server console. The control UI has zero visibility
into whether the running version is outdated, which means operators
have no idea they're exposed unless they happen to read server logs.
OpenClaw's user base is broadening well beyond developers who live in
terminals. Self-hosters, small teams, and non-technical operators are
deploying gateways and relying on the control dashboard as their
primary management interface. For these users, security has to be
surfaced where they already are — not hidden behind CLI output they
will never see. Making version awareness frictionless and actionable
is a prerequisite for reducing that 35.4% number.
This PR adds a sticky red warning banner to the top of the control UI
content area whenever the gateway detects it is running behind the
latest published version. The banner includes an "Update now" button
wired to the existing update.run RPC (the same mechanism the config
page already uses), so operators can act immediately without switching
to a terminal.
Server side:
- Cache the update check result in a module-level variable with a
typed UpdateAvailable shape (currentVersion, latestVersion, channel)
- Export a getUpdateAvailable() getter for the rest of the process
- Add an optional updateAvailable field to SnapshotSchema (backward
compatible — old clients ignore it, old servers simply omit it)
- Include the cached update status in buildGatewaySnapshot() so it
is delivered to every UI client on connect and reconnect
UI side:
- Add updateAvailable to GatewayHost, AppViewState, and the app's
reactive state so it flows through the standard snapshot pipeline
- Extract updateAvailable from the hello snapshot in applySnapshot()
- Render a .update-banner.callout.danger element with role="alert"
as the first child of <main>, before the content header
- Wire the "Update now" button to runUpdate(state), the same
controller function used by the config tab
- Use position:sticky and negative margins to pin the banner
edge-to-edge at the top of the scrollable content area
2026-02-19 19:03:37 +11:00
|
|
|
currentVersion: string;
|
|
|
|
|
latestVersion: string;
|
|
|
|
|
channel: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let updateAvailableCache: UpdateAvailable | null = null;
|
|
|
|
|
|
|
|
|
|
export function getUpdateAvailable(): UpdateAvailable | null {
|
|
|
|
|
return updateAvailableCache;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 10:00:27 +01:00
|
|
|
export function resetUpdateAvailableStateForTest(): void {
|
|
|
|
|
updateAvailableCache = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 12:07:14 +00:00
|
|
|
const UPDATE_CHECK_FILENAME = "update-check.json";
|
|
|
|
|
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
2026-02-22 17:11:24 +01:00
|
|
|
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;
|
2026-01-17 12:07:14 +00:00
|
|
|
|
|
|
|
|
function shouldSkipCheck(allowInTests: boolean): boolean {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (allowInTests) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-17 12:07:14 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:11:24 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 12:07:14 +00:00
|
|
|
async function readState(statePath: string): Promise<UpdateCheckState> {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await fs.readFile(statePath, "utf-8");
|
|
|
|
|
const parsed = JSON.parse(raw) as UpdateCheckState;
|
|
|
|
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
|
|
|
} catch {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeState(statePath: string, state: UpdateCheckState): Promise<void> {
|
2026-03-02 16:44:46 +00:00
|
|
|
await writeJsonAtomic(statePath, state);
|
2026-01-17 12:07:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 10:00:27 +01:00
|
|
|
function sameUpdateAvailable(a: UpdateAvailable | null, b: UpdateAvailable | null): boolean {
|
|
|
|
|
if (a === b) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (!a || !b) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
a.currentVersion === b.currentVersion &&
|
|
|
|
|
a.latestVersion === b.latestVersion &&
|
|
|
|
|
a.channel === b.channel
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setUpdateAvailableCache(params: {
|
|
|
|
|
next: UpdateAvailable | null;
|
|
|
|
|
onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void;
|
|
|
|
|
}): void {
|
|
|
|
|
if (sameUpdateAvailable(updateAvailableCache, params.next)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
updateAvailableCache = params.next;
|
|
|
|
|
params.onUpdateAvailableChange?.(params.next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolvePersistedUpdateAvailable(state: UpdateCheckState): UpdateAvailable | null {
|
|
|
|
|
const latestVersion = state.lastAvailableVersion?.trim();
|
|
|
|
|
if (!latestVersion) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const cmp = compareSemverStrings(VERSION, latestVersion);
|
|
|
|
|
if (cmp == null || cmp >= 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const channel = state.lastAvailableTag?.trim() || DEFAULT_PACKAGE_CHANNEL;
|
|
|
|
|
return {
|
|
|
|
|
currentVersion: VERSION,
|
|
|
|
|
latestVersion,
|
|
|
|
|
channel,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:11:24 +01:00
|
|
|
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;
|
2026-02-22 17:40:42 +01:00
|
|
|
root?: string;
|
2026-02-22 17:11:24 +01:00
|
|
|
}): Promise<AutoUpdateRunResult> {
|
2026-02-22 17:40:42 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 17:11:24 +01:00
|
|
|
try {
|
2026-02-22 17:40:42 +01:00
|
|
|
const res = await runCommandWithTimeout(argv, {
|
|
|
|
|
timeoutMs: params.timeoutMs,
|
|
|
|
|
env: {
|
|
|
|
|
OPENCLAW_AUTO_UPDATE: "1",
|
2026-02-22 17:11:24 +01:00
|
|
|
},
|
2026-02-22 17:40:42 +01:00
|
|
|
});
|
2026-02-22 17:11:24 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 12:07:14 +00:00
|
|
|
export async function runGatewayUpdateCheck(params: {
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
|
|
|
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
|
|
|
|
isNixMode: boolean;
|
|
|
|
|
allowInTests?: boolean;
|
2026-02-19 10:00:27 +01:00
|
|
|
onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void;
|
2026-02-22 17:11:24 +01:00
|
|
|
runAutoUpdate?: (params: {
|
|
|
|
|
channel: "stable" | "beta";
|
|
|
|
|
timeoutMs: number;
|
2026-02-22 17:40:42 +01:00
|
|
|
root?: string;
|
2026-02-22 17:11:24 +01:00
|
|
|
}) => Promise<AutoUpdateRunResult>;
|
2026-01-17 12:07:14 +00:00
|
|
|
}): Promise<void> {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (shouldSkipCheck(Boolean(params.allowInTests))) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (params.isNixMode) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-22 17:40:42 +01:00
|
|
|
const auto = resolveAutoUpdatePolicy(params.cfg);
|
|
|
|
|
const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false;
|
|
|
|
|
if (!shouldRunUpdateHints && !auto.enabled) {
|
2026-01-31 16:19:20 +09:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-17 12:07:14 +00:00
|
|
|
|
|
|
|
|
const statePath = path.join(resolveStateDir(), UPDATE_CHECK_FILENAME);
|
|
|
|
|
const state = await readState(statePath);
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : null;
|
2026-02-22 17:40:42 +01:00
|
|
|
if (shouldRunUpdateHints) {
|
|
|
|
|
const persistedAvailable = resolvePersistedUpdateAvailable(state);
|
|
|
|
|
setUpdateAvailableCache({
|
|
|
|
|
next: persistedAvailable,
|
|
|
|
|
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
setUpdateAvailableCache({
|
|
|
|
|
next: null,
|
|
|
|
|
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-22 17:11:24 +01:00
|
|
|
const checkIntervalMs = resolveCheckIntervalMs(params.cfg);
|
2026-01-17 12:07:14 +00:00
|
|
|
if (lastCheckedAt && Number.isFinite(lastCheckedAt)) {
|
2026-02-22 17:11:24 +01:00
|
|
|
if (now - lastCheckedAt < checkIntervalMs) {
|
2026-01-31 16:19:20 +09:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-17 12:07:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
const root = await resolveOpenClawPackageRoot({
|
2026-01-17 12:07:14 +00:00
|
|
|
moduleUrl: import.meta.url,
|
|
|
|
|
argv1: process.argv[1],
|
|
|
|
|
cwd: process.cwd(),
|
|
|
|
|
});
|
|
|
|
|
const status = await checkUpdateStatus({
|
|
|
|
|
root,
|
|
|
|
|
timeoutMs: 2500,
|
|
|
|
|
fetchGit: false,
|
|
|
|
|
includeRegistry: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const nextState: UpdateCheckState = {
|
|
|
|
|
...state,
|
|
|
|
|
lastCheckedAt: new Date(now).toISOString(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (status.installKind !== "package") {
|
2026-02-19 10:00:27 +01:00
|
|
|
delete nextState.lastAvailableVersion;
|
|
|
|
|
delete nextState.lastAvailableTag;
|
2026-02-22 17:11:24 +01:00
|
|
|
clearAutoState(nextState);
|
2026-02-19 10:00:27 +01:00
|
|
|
setUpdateAvailableCache({
|
|
|
|
|
next: null,
|
|
|
|
|
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
|
|
|
|
});
|
2026-01-17 12:07:14 +00:00
|
|
|
await writeState(statePath, nextState);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 13:33:31 +00:00
|
|
|
const channel = normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL;
|
2026-01-20 16:28:25 +00:00
|
|
|
const resolved = await resolveNpmChannelTag({ channel, timeoutMs: 2500 });
|
|
|
|
|
const tag = resolved.tag;
|
|
|
|
|
if (!resolved.version) {
|
2026-01-17 12:07:14 +00:00
|
|
|
await writeState(statePath, nextState);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 16:28:25 +00:00
|
|
|
const cmp = compareSemverStrings(VERSION, resolved.version);
|
2026-01-17 12:07:14 +00:00
|
|
|
if (cmp != null && cmp < 0) {
|
2026-02-19 10:00:27 +01:00
|
|
|
const nextAvailable: UpdateAvailable = {
|
feat(ui): add update warning banner to control dashboard
SecurityScorecard's STRIKE research recently identified over 40,000
exposed OpenClaw gateway instances, with 35.4% running known-vulnerable
versions. The gateway already performs an npm update check on startup
and compares against the registry every 24 hours — but the result is
only logged to the server console. The control UI has zero visibility
into whether the running version is outdated, which means operators
have no idea they're exposed unless they happen to read server logs.
OpenClaw's user base is broadening well beyond developers who live in
terminals. Self-hosters, small teams, and non-technical operators are
deploying gateways and relying on the control dashboard as their
primary management interface. For these users, security has to be
surfaced where they already are — not hidden behind CLI output they
will never see. Making version awareness frictionless and actionable
is a prerequisite for reducing that 35.4% number.
This PR adds a sticky red warning banner to the top of the control UI
content area whenever the gateway detects it is running behind the
latest published version. The banner includes an "Update now" button
wired to the existing update.run RPC (the same mechanism the config
page already uses), so operators can act immediately without switching
to a terminal.
Server side:
- Cache the update check result in a module-level variable with a
typed UpdateAvailable shape (currentVersion, latestVersion, channel)
- Export a getUpdateAvailable() getter for the rest of the process
- Add an optional updateAvailable field to SnapshotSchema (backward
compatible — old clients ignore it, old servers simply omit it)
- Include the cached update status in buildGatewaySnapshot() so it
is delivered to every UI client on connect and reconnect
UI side:
- Add updateAvailable to GatewayHost, AppViewState, and the app's
reactive state so it flows through the standard snapshot pipeline
- Extract updateAvailable from the hello snapshot in applySnapshot()
- Render a .update-banner.callout.danger element with role="alert"
as the first child of <main>, before the content header
- Wire the "Update now" button to runUpdate(state), the same
controller function used by the config tab
- Use position:sticky and negative margins to pin the banner
edge-to-edge at the top of the scrollable content area
2026-02-19 19:03:37 +11:00
|
|
|
currentVersion: VERSION,
|
|
|
|
|
latestVersion: resolved.version,
|
|
|
|
|
channel: tag,
|
|
|
|
|
};
|
2026-02-22 17:40:42 +01:00
|
|
|
if (shouldRunUpdateHints) {
|
|
|
|
|
setUpdateAvailableCache({
|
|
|
|
|
next: nextAvailable,
|
|
|
|
|
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-19 10:00:27 +01:00
|
|
|
nextState.lastAvailableVersion = resolved.version;
|
|
|
|
|
nextState.lastAvailableTag = tag;
|
2026-01-17 12:07:14 +00:00
|
|
|
const shouldNotify =
|
2026-01-20 16:28:25 +00:00
|
|
|
state.lastNotifiedVersion !== resolved.version || state.lastNotifiedTag !== tag;
|
2026-02-22 17:40:42 +01:00
|
|
|
if (shouldRunUpdateHints && shouldNotify) {
|
2026-01-17 12:07:14 +00:00
|
|
|
params.log.info(
|
2026-01-30 03:15:10 +01:00
|
|
|
`update available (${tag}): v${resolved.version} (current v${VERSION}). Run: ${formatCliCommand("openclaw update")}`,
|
2026-01-17 12:07:14 +00:00
|
|
|
);
|
2026-01-20 16:28:25 +00:00
|
|
|
nextState.lastNotifiedVersion = resolved.version;
|
2026-01-17 12:07:14 +00:00
|
|
|
nextState.lastNotifiedTag = tag;
|
|
|
|
|
}
|
2026-02-22 17:11:24 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-02-22 17:40:42 +01:00
|
|
|
root: root ?? undefined,
|
2026-02-22 17:11:24 +01:00
|
|
|
});
|
|
|
|
|
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}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 10:00:27 +01:00
|
|
|
} else {
|
|
|
|
|
delete nextState.lastAvailableVersion;
|
|
|
|
|
delete nextState.lastAvailableTag;
|
2026-02-22 17:11:24 +01:00
|
|
|
clearAutoState(nextState);
|
2026-02-19 10:00:27 +01:00
|
|
|
setUpdateAvailableCache({
|
|
|
|
|
next: null,
|
|
|
|
|
onUpdateAvailableChange: params.onUpdateAvailableChange,
|
|
|
|
|
});
|
2026-01-17 12:07:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await writeState(statePath, nextState);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function scheduleGatewayUpdateCheck(params: {
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
|
|
|
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
|
|
|
|
isNixMode: boolean;
|
2026-02-19 10:00:27 +01:00
|
|
|
onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void;
|
2026-02-22 17:11:24 +01:00
|
|
|
}): () => 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;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-17 12:07:14 +00:00
|
|
|
}
|