diff --git a/CHANGELOG.md b/CHANGELOG.md index c4607f82824..89dac1a9d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - macOS/Gateway: fail managed LaunchAgent stop and restart when the configured gateway port remains busy after cleanup instead of reporting success while a listener survives. Fixes #73132. Thanks @BunsDev. - Telegram: ship the isolated polling worker at the root dist path used by the bundled worker loader, avoiding startup failures looking for `dist/telegram-ingress-worker.runtime.js`. - Telegram: skip unmentioned group media before download when `requireMention` is active, avoiding failed media-download replies for messages that should be ignored. Fixes #81181. (#81785) Thanks @joshavant. +- Control UI/Gateway: stop stale token-mismatch reconnect loops when no trusted device-token retry is available, and cap rendered chat history by raw tool-output size so dashboard auth/history work cannot keep degrading channel sockets. Fixes #72139. Thanks @BunsDev. - Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang. - Agents/subagents: apply `agents.defaults.subagents.model` before target agent primary models during `sessions_spawn`, so model-scoped runtimes such as `claude-cli` stay attached to default child runs. Fixes #81395. (#81783) Thanks @joshavant. - Gateway/OpenAI-compatible HTTP: parse shared JSON endpoint paths without trusting malformed Host headers, avoiding 500s before `/v1/chat/completions`, `/v1/responses`, and `/v1/embeddings` request handling. diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 1a1fbe8eb7e..831af17b1e2 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -1184,6 +1184,29 @@ describe("GatewayClient connect auth payload", () => { }); }); + it("does not auto-reconnect on token mismatch when no device-token retry is available", async () => { + loadDeviceAuthTokenMock.mockReturnValue(null); + const onReconnectPaused = vi.fn(); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + token: "shared-token", + onReconnectPaused, + }); + + const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client }); + await expectNoReconnectAfterConnectFailure({ + client, + firstWs: ws1, + connectId: firstConnect.id, + failureDetails: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true }, + }); + expect(onReconnectPaused).toHaveBeenCalledWith({ + code: 1008, + reason: "connect failed", + detailCode: "AUTH_TOKEN_MISMATCH", + }); + }); + it("keeps reconnecting on PAIRING_REQUIRED when retry hints keep reconnect active", async () => { vi.useFakeTimers(); const onReconnectPaused = vi.fn(); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index a8d1efe7905..9ad4ce16da7 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -725,18 +725,10 @@ export class GatewayClient { ) { return true; } - if (detailCode !== ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) { - return false; + if (detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) { + return !this.pendingDeviceTokenRetry; } - if (this.pendingDeviceTokenRetry) { - return false; - } - // If the endpoint is not trusted for retry, mismatch is terminal until operator action. - if (!this.isTrustedDeviceRetryEndpoint()) { - return true; - } - // Pause mismatch reconnect loops once the one-shot device-token retry is consumed. - return this.deviceTokenRetryBudgetUsed; + return false; } private shouldRetryWithStoredDeviceToken(params: { diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts index bdf10d2cdbf..93b07b9d440 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -236,6 +236,53 @@ describe("buildChatItems", () => { expect(messageRecord(groups[groups.length - 1]).content).toBe("message 104"); }); + it("budgets rendered history by tool-result content size", () => { + const largeOutput = "x".repeat(100_000); + const items = buildChatItems( + createProps({ + messages: Array.from({ length: 6 }, (_, index) => ({ + role: "assistant", + content: [ + { + type: "tool_result", + tool_use_id: `tool-${index}`, + content: largeOutput, + }, + ], + timestamp: index, + })), + }), + ); + + const groups = items.filter((item) => item.kind === "group"); + const noticeGroup = requireGroup(items[0]); + expect(messageRecord(noticeGroup).content).toBe("Showing last 2 messages (4 hidden)."); + expect(groups).toHaveLength(2); + expect(groups[1].messages).toHaveLength(2); + expect(messageRecord(groups[1], 0).timestamp).toBe(4); + expect(messageRecord(groups[1], 1).timestamp).toBe(5); + }); + + it("does not crash when history contains malformed entries", () => { + const items = buildChatItems( + createProps({ + messages: [ + null, + undefined, + { + role: "assistant", + content: "still visible", + timestamp: 1, + }, + ], + }), + ); + + const groups = items.filter((item) => item.kind === "group"); + expect(groups).toHaveLength(1); + expect(messageRecord(groups[0]).content).toBe("still visible"); + }); + it("does not collapse duplicate text messages separated by another message", () => { const groups = messageGroups({ messages: [ diff --git a/ui/src/ui/chat/build-chat-items.ts b/ui/src/ui/chat/build-chat-items.ts index 8668ad6bd68..44bb716bc87 100644 --- a/ui/src/ui/chat/build-chat-items.ts +++ b/ui/src/ui/chat/build-chat-items.ts @@ -1,9 +1,9 @@ -import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts"; +import type { ChatItem, MessageGroup, NormalizedMessage, ToolCard } from "../types/chat-types.ts"; import { isAssistantHeartbeatAckForDisplay, stripHeartbeatTokenForDisplay, } from "./heartbeat-display.ts"; -import { CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts"; +import { CHAT_HISTORY_RENDER_CHAR_BUDGET, CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts"; import { extractTextCached } from "./message-extract.ts"; import { normalizeMessage, stripMessageDisplayMetadataText } from "./message-normalizer.ts"; import { normalizeRoleForGrouping } from "./role-normalizer.ts"; @@ -66,12 +66,32 @@ function appendCanvasBlockToAssistantMessage( }; } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function safeNormalizeMessage(message: unknown): NormalizedMessage | null { + if (!asRecord(message)) { + return null; + } + try { + return normalizeMessage(message); + } catch { + return null; + } +} + function extractChatMessagePreview(toolMessage: unknown): { preview: Extract, { kind: "canvas" }>; text: string | null; timestamp: number | null; } | null { - const normalized = normalizeMessage(toolMessage); + const normalized = safeNormalizeMessage(toolMessage); + if (!normalized) { + return null; + } const cards = extractToolCards(toolMessage, "preview"); for (let index = cards.length - 1; index >= 0; index--) { const card = cards[index]; @@ -114,7 +134,7 @@ function findNearestAssistantMessageIndex( } return { index, - timestamp: normalizeMessage(item.message).timestamp ?? null, + timestamp: safeNormalizeMessage(item.message)?.timestamp ?? null, }; }) .filter(Boolean) as Array<{ index: number; timestamp: number | null }>; @@ -203,7 +223,10 @@ function groupMessages(items: ChatItem[]): Array { } function collapseDuplicateDisplaySignature(message: unknown): string | null { - const normalized = normalizeMessage(message); + const normalized = safeNormalizeMessage(message); + if (!normalized) { + return null; + } const role = normalizeRoleForGrouping(normalized.role).toLowerCase(); if (!role || role === "tool") { return null; @@ -250,7 +273,10 @@ function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem[] { } function hasRenderableNormalizedMessage(message: unknown): boolean { - const normalized = normalizeMessage(message); + const normalized = safeNormalizeMessage(message); + if (!normalized) { + return false; + } return normalized.content.length > 0 || Boolean(normalized.replyTarget); } @@ -260,7 +286,7 @@ function sanitizeStreamText(text: string): string { } function rawMessageTimestamp(message: unknown): number | null { - const timestamp = (message as { timestamp?: unknown }).timestamp; + const timestamp = asRecord(message)?.timestamp; return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : null; } @@ -301,6 +327,129 @@ function sortChatItemsByVisibleTime(items: ChatItem[]): ChatItem[] { .map(({ item }) => item); } +type RawContentEstimateState = { + visited: WeakSet; + nodes: number; +}; + +const RAW_CONTENT_ESTIMATE_MAX_DEPTH = 8; +const RAW_CONTENT_ESTIMATE_MAX_NODES = 400; + +function addCapped(total: number, amount: number, limit: number): number { + return Math.min(limit, total + Math.max(0, amount)); +} + +function estimateRawContentChars( + value: unknown, + limit: number, + state: RawContentEstimateState, + depth = 0, +): number { + if (limit <= 0) { + return 0; + } + if (typeof value === "string") { + return Math.min(value.length, limit); + } + if (!value || typeof value !== "object") { + return 0; + } + if (depth >= RAW_CONTENT_ESTIMATE_MAX_DEPTH || state.nodes >= RAW_CONTENT_ESTIMATE_MAX_NODES) { + return 0; + } + if (state.visited.has(value)) { + return 0; + } + state.visited.add(value); + state.nodes += 1; + + if (Array.isArray(value)) { + let chars = 0; + for (const item of value) { + chars = addCapped( + chars, + estimateRawContentChars(item, limit - chars, state, depth + 1), + limit, + ); + if (chars >= limit) { + break; + } + } + return chars; + } + + const record = value as Record; + let chars = 0; + for (const key of ["text", "content", "args", "arguments", "input"] as const) { + chars = addCapped( + chars, + estimateRawContentChars(record[key], limit - chars, state, depth + 1), + limit, + ); + if (chars >= limit) { + break; + } + } + return chars; +} + +function estimateMessageRenderChars(message: unknown, limit: number): number { + const record = asRecord(message); + if (!record) { + return 1; + } + const state: RawContentEstimateState = { visited: new WeakSet(), nodes: 0 }; + let chars = 0; + for (const key of ["content", "text", "args", "arguments", "input"] as const) { + chars = addCapped(chars, estimateRawContentChars(record[key], limit - chars, state), limit); + if (chars >= limit) { + break; + } + } + return Math.max(chars, 1); +} + +function isHiddenToolMessage(message: unknown, showToolCalls: boolean): boolean { + if (showToolCalls) { + return false; + } + return safeNormalizeMessage(message)?.role.toLowerCase() === "toolresult"; +} + +function countVisibleHistoryMessages(messages: unknown[], showToolCalls: boolean): number { + let count = 0; + for (const message of messages) { + if (!isHiddenToolMessage(message, showToolCalls)) { + count += 1; + } + } + return count; +} + +function resolveHistoryStartIndex(messages: unknown[], showToolCalls: boolean): number { + let visibleCount = 0; + let renderChars = 0; + let startIndex = messages.length; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (isHiddenToolMessage(message, showToolCalls)) { + continue; + } + if (visibleCount >= CHAT_HISTORY_RENDER_LIMIT) { + break; + } + const remainingBudget = Math.max(1, CHAT_HISTORY_RENDER_CHAR_BUDGET - renderChars + 1); + const messageChars = estimateMessageRenderChars(message, remainingBudget); + if (visibleCount > 0 && renderChars + messageChars > CHAT_HISTORY_RENDER_CHAR_BUDGET) { + break; + } + renderChars += messageChars; + visibleCount += 1; + startIndex = index; + } + return startIndex; +} + export function buildChatItems(props: BuildChatItemsProps): Array { let items: ChatItem[] = []; const history = (Array.isArray(props.messages) ? props.messages : []).filter( @@ -314,22 +463,33 @@ export function buildChatItems(props: BuildChatItemsProps): Array; - const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT); - if (historyStart > 0) { + const historyStart = resolveHistoryStartIndex(history, props.showToolCalls); + const hiddenHistoryCount = countVisibleHistoryMessages( + history.slice(0, historyStart), + props.showToolCalls, + ); + const visibleHistoryCount = countVisibleHistoryMessages( + history.slice(historyStart), + props.showToolCalls, + ); + if (hiddenHistoryCount > 0) { items.push({ kind: "message", key: "chat:history:notice", message: { role: "system", - content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`, + content: `Showing last ${visibleHistoryCount} messages (${hiddenHistoryCount} hidden).`, timestamp: Date.now(), }, }); } for (let i = historyStart; i < history.length; i++) { const msg = history[i]; - const normalized = normalizeMessage(msg); - const raw = msg as Record; + const normalized = safeNormalizeMessage(msg); + if (!normalized) { + continue; + } + const raw = asRecord(msg) ?? {}; const marker = raw.__openclaw as Record | undefined; if (marker && marker.kind === "compaction") { items.push({ @@ -433,7 +593,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array; + const m = asRecord(message) ?? {}; const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; if (toolCallId) { const role = typeof m.role === "string" ? m.role : "unknown"; diff --git a/ui/src/ui/chat/history-limits.ts b/ui/src/ui/chat/history-limits.ts index 5fe17cfc195..3cd1dea48fd 100644 --- a/ui/src/ui/chat/history-limits.ts +++ b/ui/src/ui/chat/history-limits.ts @@ -1 +1,2 @@ export const CHAT_HISTORY_RENDER_LIMIT = 100; +export const CHAT_HISTORY_RENDER_CHAR_BUDGET = 240_000; diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index a160a4a73e4..31db40dbe1c 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -673,11 +673,13 @@ describe("GatewayBrowserClient", () => { vi.useRealTimers(); }); - it("reconnects without cached device token for DNS hosts beginning with a 127 label", async () => { + it("stops reconnecting on token mismatch for DNS hosts beginning with a 127 label", async () => { useNodeFakeTimers(); + const onClose = vi.fn(); const client = new GatewayBrowserClient({ url: "ws://127.example.invalid:18789", token: "shared-auth-token", + onClose, }); try { @@ -689,12 +691,19 @@ describe("GatewayBrowserClient", () => { await expectSocketClosed(firstWs); firstWs.emitClose(4008, "connect failed"); - await vi.advanceTimersByTimeAsync(800); - const secondWs = getLatestWebSocket(); - expect(secondWs).not.toBe(firstWs); - const { connectFrame: secondConnect } = await continueConnect(secondWs, "nonce-2"); - expect(secondConnect.params?.auth?.token).toBe("shared-auth-token"); - expect(secondConnect.params?.auth?.deviceToken).toBeUndefined(); + await vi.advanceTimersByTimeAsync(30_000); + expect(wsInstances).toHaveLength(1); + expect(onClose).toHaveBeenCalledWith({ + code: 4008, + reason: "connect failed", + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true }, + retryable: false, + retryAfterMs: undefined, + }, + }); } finally { client.stop(); vi.useRealTimers(); @@ -753,13 +762,15 @@ describe("GatewayBrowserClient", () => { vi.useRealTimers(); }); - it("continues reconnecting on first token mismatch when no retry was attempted", async () => { + it("stops reconnecting on token mismatch when no device-token retry is available", async () => { useNodeFakeTimers(); localStorage.clear(); + const onClose = vi.fn(); const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", token: "shared-auth-token", + onClose, }); const { ws: ws1, connectFrame: firstConnect } = await startConnect(client); @@ -777,8 +788,19 @@ describe("GatewayBrowserClient", () => { await expectSocketClosed(ws1); ws1.emitClose(4008, "connect failed"); - await vi.advanceTimersByTimeAsync(800); - expect(wsInstances).toHaveLength(2); + await vi.advanceTimersByTimeAsync(30_000); + expect(wsInstances).toHaveLength(1); + expect(onClose).toHaveBeenCalledWith({ + code: 4008, + reason: "connect failed", + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_TOKEN_MISMATCH" }, + retryable: false, + retryAfterMs: undefined, + }, + }); client.stop(); vi.useRealTimers(); diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 02e93a3baee..27a80073513 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -499,11 +499,10 @@ export class GatewayBrowserClient { this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); this.opts.onClose?.({ code: ev.code, reason, error: connectError }); const connectErrorCode = resolveGatewayErrorDetailCode(connectError); - if ( - connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH && - this.deviceTokenRetryBudgetUsed && - !this.pendingDeviceTokenRetry - ) { + if (connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) { + if (this.pendingDeviceTokenRetry) { + this.scheduleReconnect(); + } return; } if (!isNonRecoverableAuthError(connectError)) {