From 2ddc13cdb7cc2de5b83daa2e64b0bf51a747c2bd Mon Sep 17 00:00:00 2001 From: orlyjamie <6668807+orlyjamie@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:03:37 +1100 Subject: [PATCH] feat(ui): add update warning banner to control dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
, 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 --- src/gateway/protocol/schema/snapshot.ts | 7 +++++ src/gateway/server/health-state.ts | 3 +++ src/infra/update-startup.ts | 17 ++++++++++++ ui/src/styles/components.css | 36 +++++++++++++++++++++++++ ui/src/ui/app-gateway.ts | 3 +++ ui/src/ui/app-render.ts | 11 ++++++++ ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 6 +++++ 8 files changed, 84 insertions(+) diff --git a/src/gateway/protocol/schema/snapshot.ts b/src/gateway/protocol/schema/snapshot.ts index 1ac6ebc1a8..98e3182604 100644 --- a/src/gateway/protocol/schema/snapshot.ts +++ b/src/gateway/protocol/schema/snapshot.ts @@ -60,6 +60,13 @@ export const SnapshotSchema = Type.Object( Type.Literal("trusted-proxy"), ]), ), + updateAvailable: Type.Optional( + Type.Object({ + currentVersion: NonEmptyString, + latestVersion: NonEmptyString, + channel: NonEmptyString, + }), + ), }, { additionalProperties: false }, ); diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 3e2ef9522d..5d87538814 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -2,6 +2,7 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js"; import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; +import { getUpdateAvailable } from "../../infra/update-startup.js"; import { listSystemPresence } from "../../infra/system-presence.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { resolveGatewayAuth } from "../auth.js"; @@ -22,6 +23,7 @@ export function buildGatewaySnapshot(): Snapshot { const presence = listSystemPresence(); const uptimeMs = Math.round(process.uptime() * 1000); const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); + const updateAvailable = getUpdateAvailable() ?? undefined; // Health is async; caller should await getHealthSnapshot and replace later if needed. const emptyHealth: unknown = {}; return { @@ -39,6 +41,7 @@ export function buildGatewaySnapshot(): Snapshot { scope, }, authMode: auth.mode, + updateAvailable, }; } diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 7ef7c5c40f..5739c38cab 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -14,6 +14,18 @@ type UpdateCheckState = { lastNotifiedTag?: string; }; +type UpdateAvailable = { + currentVersion: string; + latestVersion: string; + channel: string; +}; + +let updateAvailableCache: UpdateAvailable | null = null; + +export function getUpdateAvailable(): UpdateAvailable | null { + return updateAvailableCache; +} + const UPDATE_CHECK_FILENAME = "update-check.json"; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -100,6 +112,11 @@ export async function runGatewayUpdateCheck(params: { const cmp = compareSemverStrings(VERSION, resolved.version); if (cmp != null && cmp < 0) { + updateAvailableCache = { + currentVersion: VERSION, + latestVersion: resolved.version, + channel: tag, + }; const shouldNotify = state.lastNotifiedVersion !== resolved.version || state.lastNotifiedTag !== tag; if (shouldNotify) { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 0b1d56ef77..77f1212919 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,5 +1,41 @@ @import "./chat.css"; +/* =========================================== + Update Banner + =========================================== */ + +.update-banner { + position: sticky; + top: 0; + z-index: 10; + margin: 0 calc(-1 * var(--shell-pad)) 0; + border-radius: 0; + border-left: none; + border-right: none; + text-align: center; + font-weight: 500; + padding: 10px 16px; +} + +.update-banner code { + background: rgba(239, 68, 68, 0.15); + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; +} + +.update-banner__btn { + margin-left: 8px; + border-color: var(--danger); + color: var(--danger); + font-size: 12px; + padding: 4px 12px; +} + +.update-banner__btn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.15); +} + /* =========================================== Cards - Refined with depth =========================================== */ diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index aaa02c4440..8200c79757 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -54,6 +54,7 @@ type GatewayHost = { refreshSessionsAfterChat: Set; execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; + updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; }; type SessionDefaultsSnapshot = { @@ -278,6 +279,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { presence?: PresenceEntry[]; health?: HealthSnapshot; sessionDefaults?: SessionDefaultsSnapshot; + updateAvailable?: { currentVersion: string; latestVersion: string; channel: string }; } | undefined; if (snapshot?.presence && Array.isArray(snapshot.presence)) { @@ -289,4 +291,5 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { if (snapshot?.sessionDefaults) { applySessionDefaults(host, snapshot.sessionDefaults); } + host.updateAvailable = snapshot?.updateAvailable ?? null; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index ee47ea94a9..c5e7dc435f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -188,6 +188,17 @@ export function renderApp(state: AppViewState) {
+ ${state.updateAvailable + ? html`` + : nothing}
${state.tab === "usage" ? nothing : html`
${titleForTab(state.tab)}
`} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ea41b4b073..ff6b004294 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -221,6 +221,7 @@ export type AppViewState = { logsLimit: number; logsMaxBytes: number; logsAtBottom: boolean; + updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; client: GatewayBrowserClient | null; refreshSessionsAfterChat: Set; connect: () => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 84e39067ba..22ca6d1d4b 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -300,6 +300,12 @@ export class OpenClawApp extends LitElement { @state() cronRuns: CronRunLogEntry[] = []; @state() cronBusy = false; + @state() updateAvailable: { + currentVersion: string; + latestVersion: string; + channel: string; + } | null = null; + @state() skillsLoading = false; @state() skillsReport: SkillStatusReport | null = null; @state() skillsError: string | null = null;