fix(ui): land #28608 from @KimGLee

Landed from contributor PR #28608 by @KimGLee.

Co-authored-by: Kim <150593189+KimGLee@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-03-07 23:25:28 +00:00
parent 1d1757b16f
commit 5f26970200
3 changed files with 88 additions and 1 deletions

View File

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

View File

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

View File

@@ -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"}`;