From a5e9f3ed427357df9af4c9329e2cf4675b842228 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:28:31 -0600 Subject: [PATCH] Gateway: fix control-ui version version-reporting consistency --- CHANGELOG.md | 1 + src/gateway/client.ts | 1 - src/gateway/control-ui.ts | 2 +- ui/src/ui/app-gateway.node.test.ts | 28 ++++++- ui/src/ui/app-gateway.ts | 33 +++++++- ui/src/ui/app-lifecycle-connect.node.test.ts | 82 ++++++++++++++++++++ ui/src/ui/app-lifecycle.ts | 6 +- 7 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 ui/src/ui/app-lifecycle-connect.node.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ccec3cfcf2..adedfa7a763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,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. - 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. - Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd. diff --git a/src/gateway/client.ts b/src/gateway/client.ts index bcf5edd72fb..a22d3471bb4 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -13,7 +13,6 @@ import { } from "../infra/device-identity.js"; import { clearDevicePairing } from "../infra/device-pairing.js"; import { normalizeFingerprint } from "../infra/tls/fingerprint.js"; -import { VERSION } from "../version.js"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; import { diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index de8b36d47d6..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, @@ -25,7 +26,6 @@ import { normalizeControlUiBasePath, resolveAssistantAvatarUrl, } from "./control-ui-shared.js"; -import { resolveRuntimeServiceVersion } from "../version.js"; const ROOT_PREFIX = "/"; const CONTROL_UI_ASSETS_MISSING_MESSAGE = diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 0b333814289..c386e14d093 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,25 @@ 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("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 e61e3e8b658..805fcb31570 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -85,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 expectedWsProtocol = page.protocol === "https:" ? "wss:" : "ws:"; + if (gateway.protocol !== expectedWsProtocol || gateway.host !== page.host) { + return undefined; + } + return serverVersion; + } catch { + return undefined; + } +} + function normalizeSessionKeyForDefaults( value: string | undefined, defaults: SessionDefaultsSnapshot, @@ -146,12 +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: host.serverVersion ?? undefined, + 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..43a4d42480e --- /dev/null +++ b/ui/src/ui/app-lifecycle-connect.node.test.ts @@ -0,0 +1,82 @@ +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, + 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); + }); +}); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index cd603b96aa5..cc8a2f2cdb4 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -43,13 +43,15 @@ type LifecycleHost = { export function handleConnected(host: LifecycleHost) { 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(() => + 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]);