Gateway: fix control-ui version version-reporting consistency

This commit is contained in:
Tak Hoffman
2026-03-04 23:28:31 -06:00
parent 2c87c5b1ba
commit a5e9f3ed42
7 changed files with 147 additions and 6 deletions

View File

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

View File

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

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

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

View File

@@ -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) => {

View File

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

View File

@@ -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<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(() =>
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]);