From 3a6b412f00a6f698159388f214db0ea92d25e3c1 Mon Sep 17 00:00:00 2001 From: Sid Date: Thu, 5 Mar 2026 14:01:34 +0800 Subject: [PATCH] fix(gateway): pass actual version to Control UI client instead of dev (#35230) * fix(gateway): pass actual version to Control UI client instead of "dev" The GatewayClient, CLI WS client, and browser Control UI all sent "dev" as their clientVersion during handshake, making it impossible to distinguish builds in gateway logs and health snapshots. - GatewayClient and CLI WS client now use the resolved VERSION constant - Control UI reads serverVersion from the bootstrap endpoint and forwards it when connecting - Bootstrap contract extended with serverVersion field Closes #35209 * Gateway: fix control-ui version version-reporting consistency * Control UI: guard deferred bootstrap connect after disconnect * fix(ui): accept same-origin http and relative gateway URLs for client version --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/gateway/call.ts | 3 +- src/gateway/client.ts | 3 +- src/gateway/control-ui-contract.ts | 1 + src/gateway/control-ui.ts | 2 + ui/src/ui/app-gateway.node.test.ts | 48 +++++++- ui/src/ui/app-gateway.ts | 33 ++++++ ui/src/ui/app-lifecycle-connect.node.test.ts | 103 ++++++++++++++++++ ui/src/ui/app-lifecycle.node.test.ts | 2 + ui/src/ui/app-lifecycle.ts | 13 ++- ui/src/ui/app.ts | 2 + .../controllers/control-ui-bootstrap.test.ts | 5 + ui/src/ui/controllers/control-ui-bootstrap.ts | 2 + ui/src/ui/gateway.ts | 2 +- 14 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 ui/src/ui/app-lifecycle-connect.node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f7f8840f5f0..bc3e1369b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Skills/native command deduplication: centralize skill command dedupe by canonical `skillName` in `listSkillCommandsForAgents` so duplicate suffixed variants (for example `_2`) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205. - Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (`&`, `"`, `<`, `>`, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin. - Agents/thinking-tag promotion hardening: guard `promoteThinkingTagsToBlocks` against malformed assistant content entries (`null`/`undefined`) before `block.type` reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin. +- Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid `dev` placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap `serverVersion` to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI. - Web UI/config form: treat `additionalProperties: true` object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai. - Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune. - Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd. diff --git a/src/gateway/call.ts b/src/gateway/call.ts index d52ffcc6d08..ba1e079e455 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -17,6 +17,7 @@ import { type GatewayClientMode, type GatewayClientName, } from "../utils/message-channel.js"; +import { VERSION } from "../version.js"; import { GatewayClient } from "./client.js"; import { resolveGatewayCredentialsFromConfig } from "./credentials.js"; import { @@ -628,7 +629,7 @@ async function executeGatewayRequestWithScopes(params: { instanceId: opts.instanceId ?? randomUUID(), clientName: opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI, clientDisplayName: opts.clientDisplayName, - clientVersion: opts.clientVersion ?? "dev", + clientVersion: opts.clientVersion ?? VERSION, platform: opts.platform, mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", diff --git a/src/gateway/client.ts b/src/gateway/client.ts index a887c757df1..a22d3471bb4 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -21,6 +21,7 @@ import { type GatewayClientMode, type GatewayClientName, } from "../utils/message-channel.js"; +import { VERSION } from "../version.js"; import { buildDeviceAuthPayloadV3 } from "./device-auth.js"; import { isSecureWebSocketUrl } from "./net.js"; import { @@ -302,7 +303,7 @@ export class GatewayClient { client: { id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, displayName: this.opts.clientDisplayName, - version: this.opts.clientVersion ?? "dev", + version: this.opts.clientVersion ?? VERSION, platform, deviceFamily: this.opts.deviceFamily, mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index 654835e0424..b53eca81db5 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -5,4 +5,5 @@ export type ControlUiBootstrapConfig = { assistantName: string; assistantAvatar: string; assistantAgentId: string; + serverVersion?: string; }; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 6075e8281a5..99e1e4e4174 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -7,6 +7,7 @@ import { resolveControlUiRootSync } from "../infra/control-ui-assets.js"; import { isWithinDir } from "../infra/path-safety.js"; import { openVerifiedFileSync } from "../infra/safe-open-sync.js"; import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH, @@ -350,6 +351,7 @@ export function handleControlUiHttpRequest( assistantName: identity.name, assistantAvatar: avatarValue ?? identity.avatar, assistantAgentId: identity.agentId, + serverVersion: resolveRuntimeServiceVersion(process.env), } satisfies ControlUiBootstrapConfig); return true; } diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 0b333814289..6915a30f999 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js"; -import { connectGateway } from "./app-gateway.ts"; +import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts"; type GatewayClientMock = { start: ReturnType; stop: ReturnType; + options: { clientVersion?: string }; emitClose: (info: { code: number; reason?: string; @@ -34,6 +35,7 @@ vi.mock("./gateway.ts", () => { constructor( private opts: { + clientVersion?: string; onClose?: (info: { code: number; reason: string; @@ -46,6 +48,7 @@ vi.mock("./gateway.ts", () => { gatewayClientInstances.push({ start: this.start, stop: this.stop, + options: { clientVersion: this.opts.clientVersion }, emitClose: (info) => { this.opts.onClose?.({ code: info.code, @@ -100,6 +103,7 @@ function createHost() { assistantName: "OpenClaw", assistantAvatar: null, assistantAgentId: null, + serverVersion: null, sessionKey: "main", chatRunId: null, refreshSessionsAfterChat: new Set(), @@ -227,3 +231,45 @@ describe("connectGateway", () => { expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH"); }); }); + +describe("resolveControlUiClientVersion", () => { + it("returns serverVersion for same-origin websocket targets", () => { + expect( + resolveControlUiClientVersion({ + gatewayUrl: "ws://localhost:8787", + serverVersion: "2026.3.3", + pageUrl: "http://localhost:8787/openclaw/", + }), + ).toBe("2026.3.3"); + }); + + it("returns serverVersion for same-origin relative targets", () => { + expect( + resolveControlUiClientVersion({ + gatewayUrl: "/ws", + serverVersion: "2026.3.3", + pageUrl: "https://control.example.com/openclaw/", + }), + ).toBe("2026.3.3"); + }); + + it("returns serverVersion for same-origin http targets", () => { + expect( + resolveControlUiClientVersion({ + gatewayUrl: "https://control.example.com/ws", + serverVersion: "2026.3.3", + pageUrl: "https://control.example.com/openclaw/", + }), + ).toBe("2026.3.3"); + }); + + it("omits serverVersion for cross-origin targets", () => { + expect( + resolveControlUiClientVersion({ + gatewayUrl: "wss://gateway.example.com", + serverVersion: "2026.3.3", + pageUrl: "https://control.example.com/openclaw/", + }), + ).toBeUndefined(); + }); +}); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index aa324c32b4c..15b885be26a 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -69,6 +69,7 @@ type GatewayHost = { assistantName: string; assistantAvatar: string | null; assistantAgentId: string | null; + serverVersion: string | null; sessionKey: string; chatRunId: string | null; refreshSessionsAfterChat: Set; @@ -84,6 +85,33 @@ type SessionDefaultsSnapshot = { scope?: string; }; +export function resolveControlUiClientVersion(params: { + gatewayUrl: string; + serverVersion: string | null; + pageUrl?: string; +}): string | undefined { + const serverVersion = params.serverVersion?.trim(); + if (!serverVersion) { + return undefined; + } + const pageUrl = + params.pageUrl ?? (typeof window === "undefined" ? undefined : window.location.href); + if (!pageUrl) { + return undefined; + } + try { + const page = new URL(pageUrl); + const gateway = new URL(params.gatewayUrl, page); + const allowedProtocols = new Set(["ws:", "wss:", "http:", "https:"]); + if (!allowedProtocols.has(gateway.protocol) || gateway.host !== page.host) { + return undefined; + } + return serverVersion; + } catch { + return undefined; + } +} + function normalizeSessionKeyForDefaults( value: string | undefined, defaults: SessionDefaultsSnapshot, @@ -145,11 +173,16 @@ export function connectGateway(host: GatewayHost) { host.execApprovalError = null; const previousClient = host.client; + const clientVersion = resolveControlUiClientVersion({ + gatewayUrl: host.settings.gatewayUrl, + serverVersion: host.serverVersion, + }); const client = new GatewayBrowserClient({ url: host.settings.gatewayUrl, token: host.settings.token.trim() ? host.settings.token : undefined, password: host.password.trim() ? host.password : undefined, clientName: "openclaw-control-ui", + clientVersion, mode: "webchat", instanceId: host.clientInstanceId, onHello: (hello) => { diff --git a/ui/src/ui/app-lifecycle-connect.node.test.ts b/ui/src/ui/app-lifecycle-connect.node.test.ts new file mode 100644 index 00000000000..0e0c425bee9 --- /dev/null +++ b/ui/src/ui/app-lifecycle-connect.node.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +const connectGatewayMock = vi.fn(); +const loadBootstrapMock = vi.fn(); + +vi.mock("./app-gateway.ts", () => ({ + connectGateway: connectGatewayMock, +})); + +vi.mock("./controllers/control-ui-bootstrap.ts", () => ({ + loadControlUiBootstrapConfig: loadBootstrapMock, +})); + +vi.mock("./app-settings.ts", () => ({ + applySettingsFromUrl: vi.fn(), + attachThemeListener: vi.fn(), + detachThemeListener: vi.fn(), + inferBasePath: vi.fn(() => "/"), + syncTabWithLocation: vi.fn(), + syncThemeWithSettings: vi.fn(), +})); + +vi.mock("./app-polling.ts", () => ({ + startLogsPolling: vi.fn(), + startNodesPolling: vi.fn(), + stopLogsPolling: vi.fn(), + stopNodesPolling: vi.fn(), + startDebugPolling: vi.fn(), + stopDebugPolling: vi.fn(), +})); + +vi.mock("./app-scroll.ts", () => ({ + observeTopbar: vi.fn(), + scheduleChatScroll: vi.fn(), + scheduleLogsScroll: vi.fn(), +})); + +import { handleConnected } from "./app-lifecycle.ts"; + +function createHost() { + return { + basePath: "", + client: null, + connectGeneration: 0, + connected: false, + tab: "chat", + assistantName: "OpenClaw", + assistantAvatar: null, + assistantAgentId: null, + serverVersion: null, + chatHasAutoScrolled: false, + chatManualRefreshInFlight: false, + chatLoading: false, + chatMessages: [], + chatToolMessages: [], + chatStream: "", + logsAutoFollow: false, + logsAtBottom: true, + logsEntries: [], + popStateHandler: vi.fn(), + topbarObserver: null, + }; +} + +describe("handleConnected", () => { + it("waits for bootstrap load before first gateway connect", async () => { + let resolveBootstrap!: () => void; + loadBootstrapMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveBootstrap = resolve; + }), + ); + connectGatewayMock.mockReset(); + const host = createHost(); + + handleConnected(host as never); + expect(connectGatewayMock).not.toHaveBeenCalled(); + + resolveBootstrap(); + await Promise.resolve(); + expect(connectGatewayMock).toHaveBeenCalledTimes(1); + }); + + it("skips deferred connect when disconnected before bootstrap resolves", async () => { + let resolveBootstrap!: () => void; + loadBootstrapMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveBootstrap = resolve; + }), + ); + connectGatewayMock.mockReset(); + const host = createHost(); + + handleConnected(host as never); + expect(connectGatewayMock).not.toHaveBeenCalled(); + + host.connectGeneration += 1; + resolveBootstrap(); + await Promise.resolve(); + + expect(connectGatewayMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/ui/app-lifecycle.node.test.ts b/ui/src/ui/app-lifecycle.node.test.ts index 13fccdd8679..b15a13eb069 100644 --- a/ui/src/ui/app-lifecycle.node.test.ts +++ b/ui/src/ui/app-lifecycle.node.test.ts @@ -5,6 +5,7 @@ function createHost() { return { basePath: "", client: { stop: vi.fn() }, + connectGeneration: 0, connected: true, tab: "chat", assistantName: "OpenClaw", @@ -35,6 +36,7 @@ describe("handleDisconnected", () => { handleDisconnected(host as unknown as Parameters[0]); expect(removeSpy).toHaveBeenCalledWith("popstate", host.popStateHandler); + expect(host.connectGeneration).toBe(1); expect(host.client).toBeNull(); expect(host.connected).toBe(false); expect(disconnectSpy).toHaveBeenCalledTimes(1); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 36527c161fc..815947d6972 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -22,11 +22,13 @@ import type { Tab } from "./navigation.ts"; type LifecycleHost = { basePath: string; client?: { stop: () => void } | null; + connectGeneration: number; connected?: boolean; tab: Tab; assistantName: string; assistantAvatar: string | null; assistantAgentId: string | null; + serverVersion: string | null; chatHasAutoScrolled: boolean; chatManualRefreshInFlight: boolean; chatLoading: boolean; @@ -41,14 +43,20 @@ type LifecycleHost = { }; export function handleConnected(host: LifecycleHost) { + const connectGeneration = ++host.connectGeneration; host.basePath = inferBasePath(); - void loadControlUiBootstrapConfig(host); + const bootstrapReady = loadControlUiBootstrapConfig(host); applySettingsFromUrl(host as unknown as Parameters[0]); syncTabWithLocation(host as unknown as Parameters[0], true); syncThemeWithSettings(host as unknown as Parameters[0]); attachThemeListener(host as unknown as Parameters[0]); window.addEventListener("popstate", host.popStateHandler); - connectGateway(host as unknown as Parameters[0]); + void bootstrapReady.finally(() => { + if (host.connectGeneration !== connectGeneration) { + return; + } + connectGateway(host as unknown as Parameters[0]); + }); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { startLogsPolling(host as unknown as Parameters[0]); @@ -63,6 +71,7 @@ export function handleFirstUpdated(host: LifecycleHost) { } export function handleDisconnected(host: LifecycleHost) { + host.connectGeneration += 1; window.removeEventListener("popstate", host.popStateHandler); stopNodesPolling(host as unknown as Parameters[0]); stopLogsPolling(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 3b50922bdfc..799ea9100c6 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -111,6 +111,7 @@ function resolveOnboardingMode(): boolean { export class OpenClawApp extends LitElement { private i18nController = new I18nController(this); clientInstanceId = generateUUID(); + connectGeneration = 0; @state() settings: UiSettings = loadSettings(); constructor() { super(); @@ -135,6 +136,7 @@ export class OpenClawApp extends LitElement { @state() assistantName = bootAssistantIdentity.name; @state() assistantAvatar = bootAssistantIdentity.avatar; @state() assistantAgentId = bootAssistantIdentity.agentId ?? null; + @state() serverVersion: string | null = null; @state() sessionKey = this.settings.sessionKey; @state() chatLoading = false; diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index 29e66fab854..fbe0750ac27 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -13,6 +13,7 @@ describe("loadControlUiBootstrapConfig", () => { assistantName: "Ops", assistantAvatar: "O", assistantAgentId: "main", + serverVersion: "2026.3.2", }), }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); @@ -22,6 +23,7 @@ describe("loadControlUiBootstrapConfig", () => { assistantName: "Assistant", assistantAvatar: null, assistantAgentId: null, + serverVersion: null, }; await loadControlUiBootstrapConfig(state); @@ -33,6 +35,7 @@ describe("loadControlUiBootstrapConfig", () => { expect(state.assistantName).toBe("Ops"); expect(state.assistantAvatar).toBe("O"); expect(state.assistantAgentId).toBe("main"); + expect(state.serverVersion).toBe("2026.3.2"); vi.unstubAllGlobals(); }); @@ -46,6 +49,7 @@ describe("loadControlUiBootstrapConfig", () => { assistantName: "Assistant", assistantAvatar: null, assistantAgentId: null, + serverVersion: null, }; await loadControlUiBootstrapConfig(state); @@ -68,6 +72,7 @@ describe("loadControlUiBootstrapConfig", () => { assistantName: "Assistant", assistantAvatar: null, assistantAgentId: null, + serverVersion: null, }; await loadControlUiBootstrapConfig(state); diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index a996e1265d3..6542fe1a9ba 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -10,6 +10,7 @@ export type ControlUiBootstrapState = { assistantName: string; assistantAvatar: string | null; assistantAgentId: string | null; + serverVersion: string | null; }; export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) { @@ -43,6 +44,7 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat state.assistantName = normalized.name; state.assistantAvatar = normalized.avatar; state.assistantAgentId = normalized.agentId ?? null; + state.serverVersion = parsed.serverVersion ?? null; } catch { // Ignore bootstrap failures; UI will update identity after connecting. } diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 5d0c4e73f2f..d8fd305ae3e 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -233,7 +233,7 @@ export class GatewayBrowserClient { maxProtocol: 3, client: { id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: this.opts.clientVersion ?? "dev", + version: this.opts.clientVersion ?? "control-ui", platform: this.opts.platform ?? navigator.platform ?? "web", mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, instanceId: this.opts.instanceId,