mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 18:14:46 +00:00
fix(gateway): stop stale control ui auth retry loops
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const CHAT_HISTORY_RENDER_LIMIT = 100;
|
||||
export const CHAT_HISTORY_RENDER_CHAR_BUDGET = 240_000;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user