fix(gateway): stop stale control ui auth retry loops

This commit is contained in:
Val Alexander
2026-05-14 04:30:08 -05:00
parent a2963f51d5
commit d9692555ee
8 changed files with 284 additions and 39 deletions

View File

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

View File

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

View File

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

View File

@@ -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: [

View File

@@ -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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<NonNullable<ToolCard["preview"]>, { 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<ChatItem | MessageGroup> {
}
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<object>;
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<string, unknown>;
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<object>(), 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<ChatItem | MessageGroup> {
let items: ChatItem[] = [];
const history = (Array.isArray(props.messages) ? props.messages : []).filter(
@@ -314,22 +463,33 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
text: string | null;
timestamp: number | null;
}>;
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<string, unknown>;
const normalized = safeNormalizeMessage(msg);
if (!normalized) {
continue;
}
const raw = asRecord(msg) ?? {};
const marker = raw.__openclaw as Record<string, unknown> | undefined;
if (marker && marker.kind === "compaction") {
items.push({
@@ -433,7 +593,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
}
function messageKey(message: unknown, index: number): string {
const m = message as Record<string, unknown>;
const m = asRecord(message) ?? {};
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
if (toolCallId) {
const role = typeof m.role === "string" ? m.role : "unknown";

View File

@@ -1 +1,2 @@
export const CHAT_HISTORY_RENDER_LIMIT = 100;
export const CHAT_HISTORY_RENDER_CHAR_BUDGET = 240_000;

View File

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

View File

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