From b4dbe0329881ae2ed25b6e53702d52fdffec5dc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 10:00:27 +0100 Subject: [PATCH] refactor: unify restart gating and update availability sync --- docs/cli/gateway.md | 2 +- docs/tools/index.md | 2 +- docs/tools/slash-commands.md | 2 +- src/agents/tools/gateway-tool.ts | 5 +- ...lt-model-status-not-configured.e2e.test.ts | 10 +- src/auto-reply/reply/commands-session.ts | 5 +- src/cli/gateway-cli/run-loop.ts | 2 +- src/config/commands.test.ts | 11 +++ src/config/commands.ts | 4 + src/config/schema.help.ts | 2 +- src/config/types.messages.ts | 2 +- src/config/zod-schema.session.ts | 4 +- src/gateway/events.ts | 7 ++ src/gateway/server-methods-list.ts | 2 + src/gateway/server-reload-handlers.ts | 5 +- src/gateway/server.impl.ts | 17 +++- src/infra/update-startup.test.ts | 94 ++++++++++++++++++- src/infra/update-startup.ts | 78 ++++++++++++++- src/macos/gateway-daemon.ts | 2 +- ui/src/styles/components.css | 7 -- ui/src/ui/app-gateway.node.test.ts | 34 +++++++ ui/src/ui/app-gateway.ts | 22 ++++- ui/src/ui/app-view-state.ts | 2 +- ui/src/ui/app.ts | 6 +- ui/src/ui/types.ts | 2 + 25 files changed, 288 insertions(+), 41 deletions(-) create mode 100644 src/gateway/events.ts diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 64724761a1..69082c5f1c 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -37,7 +37,7 @@ Notes: - By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs. - Binding beyond loopback without auth is blocked (safety guardrail). -- `SIGUSR1` triggers an in-process restart when authorized (enable `commands.restart` or use the gateway tool/config apply/update). +- `SIGUSR1` triggers an in-process restart when authorized (`commands.restart` is enabled by default; set `commands.restart: false` to block manual restart, while gateway tool/config apply/update remain allowed). - `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit. ### Options diff --git a/docs/tools/index.md b/docs/tools/index.md index 24f4016a4a..8540563309 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -453,7 +453,7 @@ Core actions: Notes: - Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. -- `restart` is disabled by default; enable with `commands.restart: true`. +- `restart` is enabled by default; set `commands.restart: false` to disable it. ### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn` / `session_status` diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index af952b8417..b8735d7e24 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -118,7 +118,7 @@ Notes: - For full provider usage breakdown, use `openclaw status --usage`. - `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`. - `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs. -- `/restart` is disabled by default; set `commands.restart: true` to enable it. +- `/restart` is enabled by default; set `commands.restart: false` to disable it. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 0a71b8a39c..ea25f81c54 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -1,4 +1,5 @@ import { Type } from "@sinclair/typebox"; +import { isRestartEnabled } from "../../config/commands.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveConfigSnapshotHash } from "../../config/io.js"; import { extractDeliveryInfo } from "../../config/sessions.js"; @@ -75,8 +76,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 (!isRestartEnabled(opts?.config)) { + 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.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts index c0220f4a56..d68b414d0f 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts @@ -58,7 +58,7 @@ describe("trigger handling", () => { ); }); }); - it("rejects /restart by default", async () => { + it("restarts by default", async () => { await withTempHome(async (home) => { const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); const res = await getReplyFromConfig( @@ -72,14 +72,14 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("/restart is disabled"); + expect(text?.startsWith("⚙️ Restarting") || text?.startsWith("⚠️ Restart failed")).toBe(true); expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); - it("restarts when enabled", async () => { + it("rejects /restart when explicitly disabled", async () => { await withTempHome(async (home) => { const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const cfg = { ...makeCfg(home), commands: { restart: true } } as OpenClawConfig; + const cfg = { ...makeCfg(home), commands: { restart: false } } as OpenClawConfig; const res = await getReplyFromConfig( { Body: "/restart", @@ -91,7 +91,7 @@ describe("trigger handling", () => { cfg, ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text?.startsWith("⚙️ Restarting") || text?.startsWith("⚠️ Restart failed")).toBe(true); + expect(text).toContain("/restart is disabled"); expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 9f368c9b96..168364adce 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -1,4 +1,5 @@ import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; +import { isRestartEnabled } from "../../config/commands.js"; import type { SessionEntry } from "../../config/sessions.js"; import { updateSessionStore } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; @@ -256,11 +257,11 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm ); return { shouldContinue: false }; } - if (params.cfg.commands?.restart !== true) { + if (!isRestartEnabled(params.cfg)) { 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/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 12d1bf1f19..8a54a33f34 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -124,7 +124,7 @@ export async function runGatewayLoop(params: { const authorized = consumeGatewaySigusr1RestartAuthorization(); if (!authorized && !isGatewaySigusr1RestartExternallyAllowed()) { gatewayLog.warn( - "SIGUSR1 restart ignored (not authorized; enable commands.restart or use gateway tool).", + "SIGUSR1 restart ignored (not authorized; commands.restart=false or use gateway tool).", ); return; } diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index ab2e1fcef3..eb90607674 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isRestartEnabled, isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, @@ -97,3 +98,13 @@ describe("isNativeCommandsExplicitlyDisabled", () => { ).toBe(false); }); }); + +describe("isRestartEnabled", () => { + it("defaults to enabled unless explicitly false", () => { + expect(isRestartEnabled(undefined)).toBe(true); + expect(isRestartEnabled({})).toBe(true); + expect(isRestartEnabled({ commands: {} })).toBe(true); + expect(isRestartEnabled({ commands: { restart: true } })).toBe(true); + expect(isRestartEnabled({ commands: { restart: false } })).toBe(false); + }); +}); diff --git a/src/config/commands.ts b/src/config/commands.ts index 81f5014f94..c5b145d76d 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -61,3 +61,7 @@ export function isNativeCommandsExplicitlyDisabled(params: { } return false; } + +export function isRestartEnabled(config?: { commands?: { restart?: boolean } }): boolean { + return config?.commands?.restart !== false; +} diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 81c194016f..e618779d00 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 d63eee32d2..9a21769c60 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 224632defc..5bc55942b1 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/events.ts b/src/gateway/events.ts new file mode 100644 index 0000000000..2697dd3448 --- /dev/null +++ b/src/gateway/events.ts @@ -0,0 +1,7 @@ +import type { UpdateAvailable } from "../infra/update-startup.js"; + +export const GATEWAY_EVENT_UPDATE_AVAILABLE = "update.available" as const; + +export type GatewayUpdateAvailableEventPayload = { + updateAvailable: UpdateAvailable | null; +}; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 1bff6bf88b..31c9046c3b 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -1,4 +1,5 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; +import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "./events.js"; const BASE_METHODS = [ "health", @@ -117,4 +118,5 @@ export const GATEWAY_EVENTS = [ "voicewake.changed", "exec.approval.requested", "exec.approval.resolved", + GATEWAY_EVENT_UPDATE_AVAILABLE, ]; diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 306d3cf472..ecebbb1e2f 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -2,6 +2,7 @@ import { getActiveEmbeddedRunCount } from "../agents/pi-embedded-runner/runs.js" import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js"; import type { CliDeps } from "../cli/deps.js"; import { resolveAgentMaxConcurrent, resolveSubagentMaxConcurrent } from "../config/agent-limits.js"; +import { isRestartEnabled } from "../config/commands.js"; import type { loadConfig } from "../config/config.js"; import { startGmailWatcherWithLogs } from "../hooks/gmail-watcher-lifecycle.js"; import { stopGmailWatcher } from "../hooks/gmail-watcher.js"; @@ -48,7 +49,7 @@ export function createGatewayReloadHandlers(params: { plan: GatewayReloadPlan, nextConfig: ReturnType, ) => { - setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart === true }); + setGatewaySigusr1RestartPolicy({ allowExternal: isRestartEnabled(nextConfig) }); const state = params.getState(); const nextState = { ...state }; @@ -138,7 +139,7 @@ export function createGatewayReloadHandlers(params: { plan: GatewayReloadPlan, nextConfig: ReturnType, ) => { - setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart === true }); + setGatewaySigusr1RestartPolicy({ allowExternal: isRestartEnabled(nextConfig) }); 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 a4add4d948..6224418313 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -8,6 +8,7 @@ import type { CanvasHostServer } from "../canvas-host/server.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; import { createDefaultDeps } from "../cli/deps.js"; +import { isRestartEnabled } from "../config/commands.js"; import { CONFIG_PATH, isNixMode, @@ -49,6 +50,10 @@ import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.j import { startChannelHealthMonitor } from "./channel-health-monitor.js"; import { startGatewayConfigReloader } from "./config-reload.js"; import type { ControlUiRootState } from "./control-ui.js"; +import { + GATEWAY_EVENT_UPDATE_AVAILABLE, + type GatewayUpdateAvailableEventPayload, +} from "./events.js"; import { ExecApprovalManager } from "./exec-approval-manager.js"; import { NodeRegistry } from "./node-registry.js"; import type { startBrowserControlServerIfEnabled } from "./server-browser.js"; @@ -252,7 +257,7 @@ export async function startGatewayServer( if (diagnosticsEnabled) { startDiagnosticHeartbeat(); } - setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); + setGatewaySigusr1RestartPolicy({ allowExternal: isRestartEnabled(cfgAtStart) }); setPreRestartDeferralCheck( () => getTotalQueueSize() + getTotalPendingReplies() + getActiveEmbeddedRunCount(), ); @@ -628,7 +633,15 @@ export async function startGatewayServer( isNixMode, }); if (!minimalTestGateway) { - scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); + scheduleGatewayUpdateCheck({ + cfg: cfgAtStart, + log, + isNixMode, + onUpdateAvailableChange: (updateAvailable) => { + const payload: GatewayUpdateAvailableEventPayload = { updateAvailable }; + broadcast(GATEWAY_EVENT_UPDATE_AVAILABLE, payload, { dropIfSlow: true }); + }, + }); } const tailscaleCleanup = minimalTestGateway ? null diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 4893d06309..88f4a2cbd0 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,9 +79,14 @@ 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 () => { @@ -99,6 +106,7 @@ describe("update-startup", () => { } else { delete process.env.VITEST; } + resetUpdateAvailableStateForTest(); }); afterAll(async () => { @@ -133,6 +141,8 @@ describe("update-startup", () => { const parsed = JSON.parse(await fs.readFile(statePath, "utf-8")) as { lastNotifiedVersion?: string; lastNotifiedTag?: string; + lastAvailableVersion?: string; + lastAvailableTag?: string; }; return { log, parsed }; } @@ -144,6 +154,7 @@ describe("update-startup", () => { expect.stringContaining("update available (latest): v2.0.0"), ); expect(parsed.lastNotifiedVersion).toBe("2.0.0"); + expect(parsed.lastAvailableVersion).toBe("2.0.0"); }); it("uses latest when beta tag is older than release", async () => { @@ -155,6 +166,87 @@ describe("update-startup", () => { expect(parsed.lastNotifiedTag).toBe("latest"); }); + it("hydrates cached update from persisted state during throttle window", async () => { + const statePath = path.join(tempDir, "update-check.json"); + await fs.writeFile( + statePath, + JSON.stringify( + { + lastCheckedAt: new Date(Date.now()).toISOString(), + lastAvailableVersion: "2.0.0", + lastAvailableTag: "latest", + }, + null, + 2, + ), + "utf-8", + ); + + const onUpdateAvailableChange = vi.fn(); + await runGatewayUpdateCheck({ + cfg: { update: { channel: "stable" } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + }); + + expect(vi.mocked(checkUpdateStatus)).not.toHaveBeenCalled(); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "latest", + }); + expect(getUpdateAvailable()).toEqual({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "latest", + }); + }); + + it("emits update change callback when update state clears", async () => { + vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw"); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/opt/openclaw", + installKind: "package", + packageManager: "npm", + } satisfies UpdateCheckResult); + vi.mocked(resolveNpmChannelTag) + .mockResolvedValueOnce({ + tag: "latest", + version: "2.0.0", + }) + .mockResolvedValueOnce({ + tag: "latest", + version: "1.0.0", + }); + + const onUpdateAvailableChange = vi.fn(); + await runGatewayUpdateCheck({ + cfg: { update: { channel: "stable" } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + }); + vi.setSystemTime(new Date("2026-01-18T11:00:00Z")); + await runGatewayUpdateCheck({ + cfg: { update: { channel: "stable" } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + }); + + expect(onUpdateAvailableChange).toHaveBeenNthCalledWith(1, { + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "latest", + }); + expect(onUpdateAvailableChange).toHaveBeenNthCalledWith(2, null); + expect(getUpdateAvailable()).toBeNull(); + }); + it("skips update check when disabled in config", async () => { const log = { info: vi.fn() }; diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 5739c38cab..63f68f772b 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,10 @@ export function getUpdateAvailable(): UpdateAvailable | null { return updateAvailableCache; } +export function resetUpdateAvailableStateForTest(): void { + updateAvailableCache = null; +} + const UPDATE_CHECK_FILENAME = "update-check.json"; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -54,11 +60,54 @@ async function writeState(statePath: string, state: UpdateCheckState): Promise 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, + }; +} + export async function runGatewayUpdateCheck(params: { cfg: ReturnType; log: { info: (msg: string, meta?: Record) => void }; isNixMode: boolean; allowInTests?: boolean; + onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void; }): Promise { if (shouldSkipCheck(Boolean(params.allowInTests))) { return; @@ -74,6 +123,11 @@ export async function runGatewayUpdateCheck(params: { const state = await readState(statePath); const now = Date.now(); const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : null; + const persistedAvailable = resolvePersistedUpdateAvailable(state); + setUpdateAvailableCache({ + next: persistedAvailable, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); if (lastCheckedAt && Number.isFinite(lastCheckedAt)) { if (now - lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) { return; @@ -98,6 +152,12 @@ export async function runGatewayUpdateCheck(params: { }; if (status.installKind !== "package") { + delete nextState.lastAvailableVersion; + delete nextState.lastAvailableTag; + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); await writeState(statePath, nextState); return; } @@ -112,11 +172,17 @@ export async function runGatewayUpdateCheck(params: { const cmp = compareSemverStrings(VERSION, resolved.version); if (cmp != null && cmp < 0) { - updateAvailableCache = { + const nextAvailable: UpdateAvailable = { currentVersion: VERSION, latestVersion: resolved.version, channel: tag, }; + setUpdateAvailableCache({ + next: nextAvailable, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + nextState.lastAvailableVersion = resolved.version; + nextState.lastAvailableTag = tag; const shouldNotify = state.lastNotifiedVersion !== resolved.version || state.lastNotifiedTag !== tag; if (shouldNotify) { @@ -126,6 +192,13 @@ export async function runGatewayUpdateCheck(params: { nextState.lastNotifiedVersion = resolved.version; nextState.lastNotifiedTag = tag; } + } else { + delete nextState.lastAvailableVersion; + delete nextState.lastAvailableTag; + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); } await writeState(statePath, nextState); @@ -135,6 +208,7 @@ export function scheduleGatewayUpdateCheck(params: { cfg: ReturnType; log: { info: (msg: string, meta?: Record) => void }; isNixMode: boolean; + onUpdateAvailableChange?: (updateAvailable: UpdateAvailable | null) => void; }): void { void runGatewayUpdateCheck(params).catch(() => {}); } diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts index 90c25039b6..46fa9b4198 100644 --- a/src/macos/gateway-daemon.ts +++ b/src/macos/gateway-daemon.ts @@ -220,7 +220,7 @@ async function main() { const authorized = consumeGatewaySigusr1RestartAuthorization(); if (!authorized && !isGatewaySigusr1RestartExternallyAllowed()) { defaultRuntime.log( - "gateway: SIGUSR1 restart ignored (not authorized; enable commands.restart or use gateway tool).", + "gateway: SIGUSR1 restart ignored (not authorized; commands.restart=false or use gateway tool).", ); return; } diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 77f1212919..f38e31896c 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -17,13 +17,6 @@ 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); diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index ee52c423d4..30e4a1203c 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js"; import { connectGateway } from "./app-gateway.ts"; type GatewayClientMock = { @@ -79,6 +80,7 @@ function createHost() { refreshSessionsAfterChat: new Set(), execApprovalQueue: [], execApprovalError: null, + updateAvailable: null, } as unknown as Parameters[0]; } @@ -126,6 +128,38 @@ describe("connectGateway", () => { expect(host.eventLogBuffer[0]?.event).toBe("presence"); }); + it("applies update.available only from active client", () => { + const host = createHost(); + + connectGateway(host); + const firstClient = gatewayClientInstances[0]; + expect(firstClient).toBeDefined(); + + connectGateway(host); + const secondClient = gatewayClientInstances[1]; + expect(secondClient).toBeDefined(); + + firstClient.emitEvent({ + event: GATEWAY_EVENT_UPDATE_AVAILABLE, + payload: { + updateAvailable: { currentVersion: "1.0.0", latestVersion: "9.9.9", channel: "latest" }, + }, + }); + expect(host.updateAvailable).toBeNull(); + + secondClient.emitEvent({ + event: GATEWAY_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 8200c79757..4126b5707c 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -1,3 +1,7 @@ +import { + GATEWAY_EVENT_UPDATE_AVAILABLE, + type GatewayUpdateAvailableEventPayload, +} from "../../../src/gateway/events.js"; import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import { @@ -26,7 +30,13 @@ import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts"; import { GatewayBrowserClient } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import type { UiSettings } from "./storage.ts"; -import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types.ts"; +import type { + AgentsListResult, + PresenceEntry, + HealthSnapshot, + StatusSummary, + UpdateAvailable, +} from "./types.ts"; type GatewayHost = { settings: UiSettings; @@ -54,7 +64,7 @@ type GatewayHost = { refreshSessionsAfterChat: Set; execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; - updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; + updateAvailable: UpdateAvailable | null; }; type SessionDefaultsSnapshot = { @@ -270,6 +280,12 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { if (resolved) { host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id); } + return; + } + + if (evt.event === GATEWAY_EVENT_UPDATE_AVAILABLE) { + const payload = evt.payload as GatewayUpdateAvailableEventPayload | undefined; + host.updateAvailable = payload?.updateAvailable ?? null; } } @@ -279,7 +295,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { presence?: PresenceEntry[]; health?: HealthSnapshot; sessionDefaults?: SessionDefaultsSnapshot; - updateAvailable?: { currentVersion: string; latestVersion: string; channel: string }; + updateAvailable?: UpdateAvailable; } | undefined; if (snapshot?.presence && Array.isArray(snapshot.presence)) { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ff6b004294..a484208fe3 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -221,7 +221,7 @@ export type AppViewState = { logsLimit: number; logsMaxBytes: number; logsAtBottom: boolean; - updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; + updateAvailable: import("./types.js").UpdateAvailable | null; client: GatewayBrowserClient | null; refreshSessionsAfterChat: Set; connect: () => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 22ca6d1d4b..b03b6659e3 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -300,11 +300,7 @@ export class OpenClawApp extends LitElement { @state() cronRuns: CronRunLogEntry[] = []; @state() cronBusy = false; - @state() updateAvailable: { - currentVersion: string; - latestVersion: string; - channel: string; - } | null = null; + @state() updateAvailable: import("./types.js").UpdateAvailable | null = null; @state() skillsLoading = false; @state() skillsReport: SkillStatusReport | null = null; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index f985774bc3..307bae9388 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -1,3 +1,5 @@ +export type UpdateAvailable = import("../../../src/infra/update-startup.js").UpdateAvailable; + export type ChannelsStatusSnapshot = { ts: number; channelOrder: string[];