From 6954d941a2576a54332a06233dbd75e5f1334031 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 09:41:46 +0100 Subject: [PATCH] fix: harden update banner state + restart defaults (#20739) (thanks @orlyjamie) --- CHANGELOG.md | 1 + src/agents/tools/gateway-tool.ts | 4 +- src/auto-reply/reply/commands-session.ts | 4 +- src/config/schema.help.ts | 2 +- src/config/types.messages.ts | 2 +- src/config/zod-schema.session.ts | 4 +- src/gateway/server-methods-list.ts | 1 + src/gateway/server-reload-handlers.ts | 4 +- src/gateway/server.impl.ts | 11 ++- src/gateway/server/health-state.ts | 2 +- src/infra/update-startup.test.ts | 88 +++++++++++++++++++++++- src/infra/update-startup.ts | 77 +++++++++++++++++++-- ui/src/ui/app-gateway.node.test.ts | 33 +++++++++ ui/src/ui/app-gateway.ts | 14 +++- ui/src/ui/app-render.ts | 8 ++- 15 files changed, 231 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aca8e7a5b0..8cd8a5e520f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (`:topic:`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi. diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 0a71b8a39c9..3ea4c53a5ad 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -75,8 +75,8 @@ export function createGatewayTool(opts?: { const params = args as Record; 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() diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 9f368c9b96a..e7634f05e9d 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -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).", }, }; } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 81c194016fd..e618779d004 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -307,7 +307,7 @@ export const FIELD_HELP: Record = { "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.", diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index d63eee32d29..9a21769c605 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -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; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 224632defc9..5bc55942b17 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -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 }); diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 1bff6bf88bb..af61fc7dfec 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -108,6 +108,7 @@ export const GATEWAY_EVENTS = [ "shutdown", "health", "heartbeat", + "update.available", "cron", "node.pair.requested", "node.pair.resolved", diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 306d3cf4723..d1840fdc029 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -48,7 +48,7 @@ export function createGatewayReloadHandlers(params: { plan: GatewayReloadPlan, nextConfig: ReturnType, ) => { - 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, ) => { - setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart === true }); + setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart !== false }); const reasons = plan.restartReasons.length ? plan.restartReasons.join(", ") : plan.changedPaths.join(", "); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a4add4d9488..be1758c512b 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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 diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 5d875388149..b3a9c1f33b1 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -2,8 +2,8 @@ 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 { 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"; diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 4893d063095..3ab91db4cdb 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -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(); + }); }); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 5739c38cab8..080e2d6a375 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -12,9 +12,11 @@ type UpdateCheckState = { lastCheckedAt?: string; lastNotifiedVersion?: string; lastNotifiedTag?: string; + lastAvailableVersion?: string; + lastAvailableTag?: string; }; -type UpdateAvailable = { +export type UpdateAvailable = { currentVersion: string; latestVersion: string; channel: string; @@ -26,6 +28,45 @@ 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; @@ -59,7 +100,14 @@ export async function runGatewayUpdateCheck(params: { log: { info: (msg: string, meta?: Record) => void }; isNixMode: boolean; allowInTests?: boolean; + onUpdateAvailableChange?: (value: UpdateAvailable | null) => void; }): Promise { + const notifyIfChanged = (changed: boolean) => { + if (!changed) { + return; + } + params.onUpdateAvailableChange?.(updateAvailableCache); + }; if (shouldSkipCheck(Boolean(params.allowInTests))) { return; } @@ -72,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)) { @@ -98,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; } @@ -112,11 +164,15 @@ export async function runGatewayUpdateCheck(params: { const cmp = compareSemverStrings(VERSION, resolved.version); if (cmp != null && cmp < 0) { - updateAvailableCache = { - currentVersion: VERSION, - latestVersion: resolved.version, - channel: tag, - }; + 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) { @@ -126,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; log: { info: (msg: string, meta?: Record) => void }; isNixMode: boolean; + onUpdateAvailableChange?: (value: UpdateAvailable | null) => void; }): void { void runGatewayUpdateCheck(params).catch(() => {}); } diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index ee52c423d45..93a4d2f0fde 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -79,6 +79,7 @@ function createHost() { refreshSessionsAfterChat: new Set(), execApprovalQueue: [], execApprovalError: null, + updateAvailable: null, } as unknown as Parameters[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(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 8200c797577..3ce5dacd5a1 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -57,6 +57,12 @@ type GatewayHost = { updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; }; +type UpdateAvailableSnapshot = { + currentVersion: string; + latestVersion: string; + channel: string; +}; + type SessionDefaultsSnapshot = { defaultAgentId?: string; mainKey?: string; @@ -244,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[0]); } @@ -279,7 +291,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { presence?: PresenceEntry[]; health?: HealthSnapshot; sessionDefaults?: SessionDefaultsSnapshot; - updateAvailable?: { currentVersion: string; latestVersion: string; channel: string }; + updateAvailable?: UpdateAvailableSnapshot; } | undefined; if (snapshot?.presence && Array.isArray(snapshot.presence)) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c5e7dc435f9..a9ebc1d7cba 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -188,8 +188,9 @@ export function renderApp(state: AppViewState) {
- ${state.updateAvailable - ? html`