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

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