Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
6954d941a2 fix: harden update banner state + restart defaults (#20739) (thanks @orlyjamie) 2026-02-19 09:43:27 +01:00
orlyjamie
c5952c259a 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 09:42:58 +01:00
19 changed files with 304 additions and 13 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- UI/Sessions: accept the canonical main session-key alias in Chat UI flows so main-session routing stays consistent. (#20311) Thanks @mbelinky.
- iOS/Onboarding: prevent pairing-status flicker during auto-resume by keeping resumed state transitions stable. (#20310) Thanks @mbelinky.
- OpenClawKit/Protocol: preserve JSON boolean literals (`true`/`false`) when bridging through `AnyCodable` so Apple client RPC params no longer re-encode booleans as `1`/`0`. Thanks @mbelinky.
- Gateway/UI: keep update-warning banners accurate by persisting update-available state across restarts, broadcasting late update-check results to already-connected dashboard clients, and enabling gateway restart controls by default (`commands.restart=false` to disable). (#20739) Thanks @orlyjamie.
- iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky.
- Browser/Relay: reuse an already-running extension relay when the relay port is occupied by another OpenClaw process, while still failing on non-relay port collisions to avoid masking unrelated listeners. (#20035) Thanks @mbelinky.
- Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`<chatId>:topic:<threadId>`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi.

View File

@@ -75,8 +75,8 @@ export function createGatewayTool(opts?: {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action === "restart") {
if (opts?.config?.commands?.restart !== true) {
throw new Error("Gateway restart is disabled. Set commands.restart=true to enable.");
if (opts?.config?.commands?.restart === false) {
throw new Error("Gateway restart is disabled (commands.restart=false).");
}
const sessionKey =
typeof params.sessionKey === "string" && params.sessionKey.trim()

View File

@@ -256,11 +256,11 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
);
return { shouldContinue: false };
}
if (params.cfg.commands?.restart !== true) {
if (params.cfg.commands?.restart === false) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /restart is disabled. Set commands.restart=true to enable.",
text: "⚠️ /restart is disabled (commands.restart=false).",
},
};
}

View File

@@ -307,7 +307,7 @@ export const FIELD_HELP: Record<string, string> = {
"How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).",
"commands.config": "Allow /config chat command to read/write config on disk (default: false).",
"commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).",
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
"commands.restart": "Allow /restart and gateway restart tool actions (default: true).",
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
"commands.ownerAllowFrom":
"Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.",

View File

@@ -112,7 +112,7 @@ export type CommandsConfig = {
config?: boolean;
/** Allow /debug command (default: false). */
debug?: boolean;
/** Allow restart commands/tools (default: false). */
/** Allow restart commands/tools (default: true). */
restart?: boolean;
/** Enforce access-group allowlists/policies for commands (default: true). */
useAccessGroups?: boolean;

View File

@@ -129,11 +129,11 @@ export const CommandsSchema = z
bashForegroundMs: z.number().int().min(0).max(30_000).optional(),
config: z.boolean().optional(),
debug: z.boolean().optional(),
restart: z.boolean().optional(),
restart: z.boolean().optional().default(true),
useAccessGroups: z.boolean().optional(),
ownerAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
allowFrom: ElevatedAllowFromSchema.optional(),
})
.strict()
.optional()
.default({ native: "auto", nativeSkills: "auto" });
.default({ native: "auto", nativeSkills: "auto", restart: true });

View File

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

View File

@@ -108,6 +108,7 @@ export const GATEWAY_EVENTS = [
"shutdown",
"health",
"heartbeat",
"update.available",
"cron",
"node.pair.requested",
"node.pair.resolved",

View File

@@ -48,7 +48,7 @@ export function createGatewayReloadHandlers(params: {
plan: GatewayReloadPlan,
nextConfig: ReturnType<typeof loadConfig>,
) => {
setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart === true });
setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart !== false });
const state = params.getState();
const nextState = { ...state };
@@ -138,7 +138,7 @@ export function createGatewayReloadHandlers(params: {
plan: GatewayReloadPlan,
nextConfig: ReturnType<typeof loadConfig>,
) => {
setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart === true });
setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart !== false });
const reasons = plan.restartReasons.length
? plan.restartReasons.join(", ")
: plan.changedPaths.join(", ");

View File

@@ -252,7 +252,7 @@ export async function startGatewayServer(
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
}
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart !== false });
setPreRestartDeferralCheck(
() => getTotalQueueSize() + getTotalPendingReplies() + getActiveEmbeddedRunCount(),
);
@@ -628,7 +628,14 @@ export async function startGatewayServer(
isNixMode,
});
if (!minimalTestGateway) {
scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode });
scheduleGatewayUpdateCheck({
cfg: cfgAtStart,
log,
isNixMode,
onUpdateAvailableChange: (updateAvailable) => {
broadcast("update.available", { updateAvailable }, { dropIfSlow: true });
},
});
}
const tailscaleCleanup = minimalTestGateway
? null

View File

@@ -3,6 +3,7 @@ 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 { listSystemPresence } from "../../infra/system-presence.js";
import { getUpdateAvailable } from "../../infra/update-startup.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { resolveGatewayAuth } from "../auth.js";
import type { Snapshot } from "../protocol/index.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,
};
}

View File

@@ -49,6 +49,8 @@ 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 getUpdateAvailable: (typeof import("./update-startup.js"))["getUpdateAvailable"];
let resetUpdateAvailableStateForTest: (typeof import("./update-startup.js"))["resetUpdateAvailableStateForTest"];
let loaded = false;
beforeAll(async () => {
@@ -77,13 +79,21 @@ describe("update-startup", () => {
if (!loaded) {
({ resolveOpenClawPackageRoot } = await import("./openclaw-root.js"));
({ checkUpdateStatus, resolveNpmChannelTag } = await import("./update-check.js"));
({ runGatewayUpdateCheck } = await import("./update-startup.js"));
({ runGatewayUpdateCheck, getUpdateAvailable, resetUpdateAvailableStateForTest } =
await import("./update-startup.js"));
loaded = true;
}
vi.mocked(resolveOpenClawPackageRoot).mockReset();
vi.mocked(checkUpdateStatus).mockReset();
vi.mocked(resolveNpmChannelTag).mockReset();
resetUpdateAvailableStateForTest();
});
afterEach(async () => {
vi.useRealTimers();
if (loaded) {
resetUpdateAvailableStateForTest();
}
if (hadStateDir) {
process.env.OPENCLAW_STATE_DIR = prevStateDir;
} else {
@@ -168,4 +178,80 @@ describe("update-startup", () => {
expect(log.info).not.toHaveBeenCalled();
await expect(fs.stat(path.join(tempDir, "update-check.json"))).rejects.toThrow();
});
it("hydrates persisted update availability when throttle skips a fresh check", async () => {
const statePath = path.join(tempDir, "update-check.json");
await fs.writeFile(
statePath,
JSON.stringify({
lastCheckedAt: "2026-01-17T09:55:00Z",
lastAvailableVersion: "2.0.0",
lastAvailableTag: "latest",
}),
"utf-8",
);
const log = { info: vi.fn() };
const onUpdateAvailableChange = vi.fn();
await runGatewayUpdateCheck({
cfg: { update: { channel: "stable" } },
log,
isNixMode: false,
allowInTests: true,
onUpdateAvailableChange,
});
expect(checkUpdateStatus).not.toHaveBeenCalled();
expect(getUpdateAvailable()).toEqual({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "latest",
});
expect(onUpdateAvailableChange).toHaveBeenCalledWith({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "latest",
});
});
it("clears cached update availability when current version is up to date", 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: "1.0.0",
});
const statePath = path.join(tempDir, "update-check.json");
await fs.writeFile(
statePath,
JSON.stringify({
lastAvailableVersion: "2.0.0",
lastAvailableTag: "latest",
}),
"utf-8",
);
const onUpdateAvailableChange = vi.fn();
await runGatewayUpdateCheck({
cfg: { update: { channel: "stable" } },
log: { info: vi.fn() },
isNixMode: false,
allowInTests: true,
onUpdateAvailableChange,
});
expect(getUpdateAvailable()).toBeNull();
expect(onUpdateAvailableChange).toHaveBeenLastCalledWith(null);
const parsed = JSON.parse(await fs.readFile(statePath, "utf-8")) as {
lastAvailableVersion?: string;
lastAvailableTag?: string;
};
expect(parsed.lastAvailableVersion).toBeUndefined();
expect(parsed.lastAvailableTag).toBeUndefined();
});
});

View File

@@ -12,8 +12,61 @@ type UpdateCheckState = {
lastCheckedAt?: string;
lastNotifiedVersion?: string;
lastNotifiedTag?: string;
lastAvailableVersion?: string;
lastAvailableTag?: string;
};
export type UpdateAvailable = {
currentVersion: string;
latestVersion: string;
channel: string;
};
let updateAvailableCache: UpdateAvailable | null = null;
export function getUpdateAvailable(): UpdateAvailable | null {
return updateAvailableCache;
}
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(next: UpdateAvailable | null): boolean {
if (sameUpdateAvailable(updateAvailableCache, next)) {
return false;
}
updateAvailableCache = next;
return true;
}
function resolvePersistedUpdateAvailable(state: UpdateCheckState): UpdateAvailable | null {
const latestVersion = state.lastAvailableVersion?.trim();
const channel = state.lastAvailableTag?.trim();
if (!latestVersion || !channel) {
return null;
}
const cmp = compareSemverStrings(VERSION, latestVersion);
if (cmp != null && cmp < 0) {
return {
currentVersion: VERSION,
latestVersion,
channel,
};
}
return null;
}
const UPDATE_CHECK_FILENAME = "update-check.json";
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -47,7 +100,14 @@ export async function runGatewayUpdateCheck(params: {
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
isNixMode: boolean;
allowInTests?: boolean;
onUpdateAvailableChange?: (value: UpdateAvailable | null) => void;
}): Promise<void> {
const notifyIfChanged = (changed: boolean) => {
if (!changed) {
return;
}
params.onUpdateAvailableChange?.(updateAvailableCache);
};
if (shouldSkipCheck(Boolean(params.allowInTests))) {
return;
}
@@ -60,6 +120,7 @@ export async function runGatewayUpdateCheck(params: {
const statePath = path.join(resolveStateDir(), UPDATE_CHECK_FILENAME);
const state = await readState(statePath);
notifyIfChanged(setUpdateAvailableCache(resolvePersistedUpdateAvailable(state)));
const now = Date.now();
const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : null;
if (lastCheckedAt && Number.isFinite(lastCheckedAt)) {
@@ -86,6 +147,9 @@ export async function runGatewayUpdateCheck(params: {
};
if (status.installKind !== "package") {
delete nextState.lastAvailableVersion;
delete nextState.lastAvailableTag;
notifyIfChanged(setUpdateAvailableCache(null));
await writeState(statePath, nextState);
return;
}
@@ -100,6 +164,15 @@ export async function runGatewayUpdateCheck(params: {
const cmp = compareSemverStrings(VERSION, resolved.version);
if (cmp != null && cmp < 0) {
notifyIfChanged(
setUpdateAvailableCache({
currentVersion: VERSION,
latestVersion: resolved.version,
channel: tag,
}),
);
nextState.lastAvailableVersion = resolved.version;
nextState.lastAvailableTag = tag;
const shouldNotify =
state.lastNotifiedVersion !== resolved.version || state.lastNotifiedTag !== tag;
if (shouldNotify) {
@@ -109,15 +182,24 @@ export async function runGatewayUpdateCheck(params: {
nextState.lastNotifiedVersion = resolved.version;
nextState.lastNotifiedTag = tag;
}
} else {
delete nextState.lastAvailableVersion;
delete nextState.lastAvailableTag;
notifyIfChanged(setUpdateAvailableCache(null));
}
await writeState(statePath, nextState);
}
export function resetUpdateAvailableStateForTest(): void {
updateAvailableCache = null;
}
export function scheduleGatewayUpdateCheck(params: {
cfg: ReturnType<typeof loadConfig>;
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
isNixMode: boolean;
onUpdateAvailableChange?: (value: UpdateAvailable | null) => void;
}): void {
void runGatewayUpdateCheck(params).catch(() => {});
}

View File

@@ -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
=========================================== */

View File

@@ -79,6 +79,7 @@ function createHost() {
refreshSessionsAfterChat: new Set<string>(),
execApprovalQueue: [],
execApprovalError: null,
updateAvailable: null,
} as unknown as Parameters<typeof connectGateway>[0];
}
@@ -126,6 +127,38 @@ describe("connectGateway", () => {
expect(host.eventLogBuffer[0]?.event).toBe("presence");
});
it("updates updateAvailable from active client events only", () => {
const host = createHost();
connectGateway(host);
const firstClient = gatewayClientInstances[0];
expect(firstClient).toBeDefined();
connectGateway(host);
const secondClient = gatewayClientInstances[1];
expect(secondClient).toBeDefined();
firstClient.emitEvent({
event: "update.available",
payload: {
updateAvailable: { currentVersion: "1.0.0", latestVersion: "2.0.0", channel: "latest" },
},
});
expect(host.updateAvailable).toBeNull();
secondClient.emitEvent({
event: "update.available",
payload: {
updateAvailable: { currentVersion: "1.0.0", latestVersion: "2.0.0", channel: "latest" },
},
});
expect(host.updateAvailable).toEqual({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "latest",
});
});
it("ignores stale client onClose callbacks after reconnect", () => {
const host = createHost();

View File

@@ -54,6 +54,13 @@ type GatewayHost = {
refreshSessionsAfterChat: Set<string>;
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null;
};
type UpdateAvailableSnapshot = {
currentVersion: string;
latestVersion: string;
channel: string;
};
type SessionDefaultsSnapshot = {
@@ -243,6 +250,12 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
return;
}
if (evt.event === "update.available") {
const payload = evt.payload as { updateAvailable?: UpdateAvailableSnapshot | null } | undefined;
host.updateAvailable = payload?.updateAvailable ?? null;
return;
}
if (evt.event === "cron" && host.tab === "cron") {
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
}
@@ -278,6 +291,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
presence?: PresenceEntry[];
health?: HealthSnapshot;
sessionDefaults?: SessionDefaultsSnapshot;
updateAvailable?: UpdateAvailableSnapshot;
}
| undefined;
if (snapshot?.presence && Array.isArray(snapshot.presence)) {
@@ -289,4 +303,5 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
if (snapshot?.sessionDefaults) {
applySessionDefaults(host, snapshot.sessionDefaults);
}
host.updateAvailable = snapshot?.updateAvailable ?? null;
}

View File

@@ -188,6 +188,19 @@ export function renderApp(state: AppViewState) {
</div>
</aside>
<main class="content ${isChat ? "content--chat" : ""}">
${
state.updateAvailable
? html`<div class="update-banner callout danger" role="alert">
<strong>Update available:</strong> v${state.updateAvailable.latestVersion}
(running v${state.updateAvailable.currentVersion}).
<button
class="btn btn--sm update-banner__btn"
?disabled=${state.updateRunning || !state.connected}
@click=${() => runUpdate(state)}
>${state.updateRunning ? "Updating…" : "Update now"}</button>
</div>`
: nothing
}
<section class="content-header">
<div>
${state.tab === "usage" ? nothing : html`<div class="page-title">${titleForTab(state.tab)}</div>`}

View File

@@ -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<string>;
connect: () => void;

View File

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