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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,4 +5,5 @@ export type ControlUiBootstrapConfig = {
|
||||
assistantName: string;
|
||||
assistantAvatar: string;
|
||||
assistantAgentId: string;
|
||||
serverVersion?: string;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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