diff --git a/CHANGELOG.md b/CHANGELOG.md index f501696e1ef..9895e404b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -301,6 +301,7 @@ Docs: https://docs.openclaw.ai - Outbound delivery replay safety: use two-phase delivery ACK markers (`.json` -> `.delivered` -> unlink) and startup marker cleanup so crash windows between send and cleanup do not replay already-delivered messages. (#38668) Thanks @Gundam98. - Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting. - Nodes/system.run PowerShell wrapper parsing: treat `pwsh`/`powershell` `-EncodedCommand` forms as shell-wrapper payloads so allowlist mode still requires approval instead of falling back to plain argv analysis. Thanks @tdjackey for reporting. +- Control UI/auth error reporting: map generic browser `Fetch failed` websocket close errors back to actionable gateway auth messages (`gateway token mismatch`, `authentication failed`, `retry later`) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee. ## 2026.3.2 diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index f5ce210906c..c8ea860b72e 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js"; +import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts"; type GatewayClientMock = { @@ -209,6 +210,69 @@ describe("connectGateway", () => { expect(host.lastErrorCode).toBeNull(); }); + it("maps generic fetch-failed auth errors to actionable token mismatch message", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitClose({ + code: 4008, + reason: "connect failed", + error: { + code: "INVALID_REQUEST", + message: "Fetch failed", + details: { code: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH }, + }, + }); + + expect(host.lastErrorCode).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH); + expect(host.lastError).toContain("gateway token mismatch"); + }); + + it("maps TypeError fetch failures to actionable auth rate-limit guidance", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitClose({ + code: 4008, + reason: "connect failed", + error: { + code: "INVALID_REQUEST", + message: "TypeError: Failed to fetch", + details: { code: ConnectErrorDetailCodes.AUTH_RATE_LIMITED }, + }, + }); + + expect(host.lastErrorCode).toBe(ConnectErrorDetailCodes.AUTH_RATE_LIMITED); + expect(host.lastError).toContain("too many failed authentication attempts"); + }); + + it("preserves specific close errors even when auth detail codes are present", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitClose({ + code: 4008, + reason: "connect failed", + error: { + code: "INVALID_REQUEST", + message: "Failed to fetch gateway metadata from ws://127.0.0.1:18789", + details: { code: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH }, + }, + }); + + expect(host.lastErrorCode).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH); + expect(host.lastError).toBe("Failed to fetch gateway metadata from ws://127.0.0.1:18789"); + }); + it("prefers structured connect errors over close reason", () => { const host = createHost(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 8fd596637b7..e5285bab93b 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -2,6 +2,7 @@ import { GATEWAY_EVENT_UPDATE_AVAILABLE, type GatewayUpdateAvailableEventPayload, } from "../../../src/gateway/events.js"; +import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import { @@ -43,6 +44,24 @@ import type { UpdateAvailable, } from "./types.ts"; +function isGenericBrowserFetchFailure(message: string): boolean { + return /^(?:typeerror:\s*)?(?:fetch failed|failed to fetch)$/i.test(message.trim()); +} + +function formatAuthCloseErrorMessage(code: string | null, fallback: string): string { + const resolvedCode = code ?? ""; + if (resolvedCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) { + return "unauthorized: gateway token mismatch (open dashboard URL with current token)"; + } + if (resolvedCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED) { + return "unauthorized: too many failed authentication attempts (retry later)"; + } + if (resolvedCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED) { + return "unauthorized: authentication failed"; + } + return fallback; +} + type GatewayHost = { settings: UiSettings; password: string; @@ -218,7 +237,10 @@ export function connectGateway(host: GatewayHost) { (typeof error?.code === "string" ? error.code : null); if (code !== 1012) { if (error?.message) { - host.lastError = error.message; + host.lastError = + host.lastErrorCode && isGenericBrowserFetchFailure(error.message) + ? formatAuthCloseErrorMessage(host.lastErrorCode, error.message) + : error.message; return; } host.lastError = `disconnected (${code}): ${reason || "no reason"}`;