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>
This commit is contained in:
Sid
2026-03-05 14:01:34 +08:00
committed by GitHub
parent c4dab17ca9
commit 3a6b412f00
14 changed files with 214 additions and 6 deletions

View File

@@ -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 (`&amp;`, `&quot;`, `&lt;`, `&gt;`, 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.

View File

@@ -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<T>(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",

View File

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

View File

@@ -5,4 +5,5 @@ export type ControlUiBootstrapConfig = {
assistantName: string;
assistantAvatar: string;
assistantAgentId: string;
serverVersion?: string;
};

View File

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

View File

@@ -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<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
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<string>(),
@@ -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();
});
});

View File

@@ -69,6 +69,7 @@ type GatewayHost = {
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
serverVersion: string | null;
sessionKey: string;
chatRunId: string | null;
refreshSessionsAfterChat: Set<string>;
@@ -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) => {

View File

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

View File

@@ -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<typeof handleDisconnected>[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);

View File

@@ -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<typeof applySettingsFromUrl>[0]);
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);
window.addEventListener("popstate", host.popStateHandler);
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
void bootstrapReady.finally(() => {
if (host.connectGeneration !== connectGeneration) {
return;
}
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
});
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[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<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);

View File

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

View File

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

View File

@@ -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.
}

View File

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