mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 01:43:29 +08:00
Compare commits
4 Commits
v2026.6.10
...
fix/35209-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b75a625fbf | ||
|
|
9b0ba7bfbb | ||
|
|
a5e9f3ed42 | ||
|
|
2c87c5b1ba |
@@ -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.
|
||||
|
||||
@@ -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