mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
103
ui/src/ui/app-lifecycle-connect.node.test.ts
Normal file
103
ui/src/ui/app-lifecycle-connect.node.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user