From 96b672c54dd6f224156ebb0cebdebdc70edec8db Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 11 May 2026 07:40:47 -0500 Subject: [PATCH] Stabilize Control UI connection diagnostics (#80510) Summary: - Catch browser-side WebSocket constructor security failures and surface wss://, Tailscale, and loopback dashboard guidance. - Classify the browser WebSocket security code through Control UI login and overview insecure-context hints. - Keep the changelog attribution under the active Fixes section. Verification: - pnpm test ui/src/ui/gateway.node.test.ts ui/src/ui/views/login-gate.test.ts ui/src/ui/views/overview.node.test.ts src/logging/diagnostic.test.ts - pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/logging/diagnostic-stability.ts src/logging/diagnostic.test.ts ui/src/ui/gateway.ts ui/src/ui/gateway.node.test.ts ui/src/ui/views/login-gate.test.ts ui/src/ui/views/overview-hints.ts ui/src/ui/views/overview.node.test.ts - git diff --check origin/main...HEAD - pnpm check:changed - GitHub Real behavior proof and CI preflight passed on 1ea05289b13fab82d2f67de367b3d987f2a66131 --- CHANGELOG.md | 1 + ui/src/ui/gateway.node.test.ts | 81 +++++++++++++++++++++++++++ ui/src/ui/gateway.ts | 80 +++++++++++++++++++++++++- ui/src/ui/views/login-gate.test.ts | 30 ++++++++++ ui/src/ui/views/overview-hints.ts | 3 + ui/src/ui/views/overview.node.test.ts | 23 ++++++++ 6 files changed, 217 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d8f9d8f68d..2a1d767d7d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -428,6 +428,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI: surface browser-blocked WebSocket security failures with wss:// and loopback dashboard guidance instead of leaving the connection on a dead security error. Thanks @BunsDev. - Gateway/diagnostics: keep active-only transient event-loop max-delay samples as info-level stability telemetry instead of warning-level liveness diagnostics. Thanks @BunsDev. - Google/Gemini: default new API-key onboarding to stable `google/gemini-2.5-flash` instead of the preview Pro route, reducing surprise daily quota exhaustion. Fixes #79670. Thanks @HugeBunny. - Amazon Bedrock: expose Claude thinking profiles through the lightweight provider policy surface so `/think:adaptive` validates before the Bedrock runtime plugin is loaded. Fixes #79754. Thanks @phoenixyy and @hclsys. diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 053bcab9455b..f51d8695bb8a 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -283,6 +283,87 @@ describe("GatewayBrowserClient", () => { expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]); }); + it("reports browser security errors from WebSocket construction without retrying", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + class ThrowingWebSocket { + static OPEN = 1; + + constructor(_url: string) { + const err = new Error("Cannot connect due to a security error."); + err.name = "SecurityError"; + throw err; + } + } + vi.stubGlobal("WebSocket", ThrowingWebSocket); + + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + onClose, + }); + + expect(() => client.start()).not.toThrow(); + expect(onClose).toHaveBeenCalledWith({ + code: 1006, + reason: "security error", + error: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_SECURITY_ERROR", + message: expect.stringContaining("Use wss://"), + details: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_SECURITY_ERROR", + browserErrorName: "SecurityError", + }), + }), + }); + expect(wsInstances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(onClose).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it("reports generic WebSocket construction failures without retrying", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + class ThrowingWebSocket { + static OPEN = 1; + + constructor(_url: string) { + throw new TypeError("constructor failed"); + } + } + vi.stubGlobal("WebSocket", ThrowingWebSocket); + + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + onClose, + }); + + expect(() => client.start()).not.toThrow(); + expect(onClose).toHaveBeenCalledWith({ + code: 1006, + reason: "websocket error", + error: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + message: expect.stringContaining("Could not create the Gateway WebSocket"), + details: expect.objectContaining({ + code: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + browserErrorName: "TypeError", + browserMessage: "constructor failed", + }), + }), + }); + expect(wsInstances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(onClose).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + it("reports request timing for attributed RPC latency", async () => { const onRequestTiming = vi.fn(); const client = new GatewayBrowserClient({ diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 60f86f237644..50861faf25b4 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -246,6 +246,9 @@ export type GatewayRequestTiming = { // 4008 = application-defined code (browser rejects 1008 "Policy Violation") const CONNECT_FAILED_CLOSE_CODE = 4008; const STARTUP_RETRY_CLOSE_CODE = 4013; +const BROWSER_WEBSOCKET_CLOSE_CODE = 1006; +const BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE = "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR"; +const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR"; function buildGatewayConnectAuth( selectedAuth: SelectedConnectAuth, @@ -261,6 +264,62 @@ function buildGatewayConnectAuth( }; } +function getErrorMessage(err: unknown): string { + return err instanceof Error && err.message ? err.message : String(err); +} + +function getErrorName(err: unknown): string | undefined { + if (err instanceof Error && err.name) { + return err.name; + } + if (err && typeof err === "object" && "name" in err) { + const name = (err as { name?: unknown }).name; + return typeof name === "string" && name.trim() ? name : undefined; + } + return undefined; +} + +function isBrowserWebSocketSecurityError(err: unknown): boolean { + const name = getErrorName(err)?.toLowerCase(); + const message = getErrorMessage(err).toLowerCase(); + return ( + name === "securityerror" || + message.includes("security error") || + message.includes("mixed content") || + message.includes("insecure websocket") + ); +} + +function formatBrowserWebSocketConstructorError(err: unknown, url: string): GatewayErrorInfo { + const securityError = isBrowserWebSocketSecurityError(err); + const browserMessage = getErrorMessage(err); + const isPlaintextWs = url.trim().toLowerCase().startsWith("ws://"); + if (securityError) { + return { + code: BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, + message: + "Browser refused the Gateway WebSocket for security reasons." + + (isPlaintextWs + ? " Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789." + : " Check the Gateway WebSocket URL and browser security policy."), + details: { + code: BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, + browserErrorName: getErrorName(err), + browserMessage, + }, + }; + } + return { + code: BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE, + message: `Could not create the Gateway WebSocket: ${browserMessage}`, + details: { + code: BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR_CODE, + browserErrorName: getErrorName(err), + browserMessage, + }, + }; +} + async function buildGatewayConnectDevice(params: { deviceIdentity: Awaited> | null; client: GatewayConnectClientInfo; @@ -350,7 +409,26 @@ export class GatewayBrowserClient { if (this.closed) { return; } - const ws = new WebSocket(this.opts.url); + let ws: WebSocket; + try { + ws = new WebSocket(this.opts.url); + } catch (err) { + const error = formatBrowserWebSocketConstructorError(err, this.opts.url); + this.ws = null; + this.pendingConnectError = undefined; + this.pendingDeviceTokenRetry = false; + this.pendingStartupReconnectDelayMs = null; + this.flushPending(new Error(error.message)); + this.opts.onClose?.({ + code: BROWSER_WEBSOCKET_CLOSE_CODE, + reason: + error.code === BROWSER_WEBSOCKET_SECURITY_ERROR_CODE + ? "security error" + : "websocket error", + error, + }); + return; + } const generation = ++this.connectGeneration; this.ws = ws; ws.addEventListener("open", () => this.queueConnect(ws, generation)); diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts index 219f2dcf5ee5..13212c78f6c2 100644 --- a/ui/src/ui/views/login-gate.test.ts +++ b/ui/src/ui/views/login-gate.test.ts @@ -115,6 +115,36 @@ describe("resolveLoginFailureFeedback", () => { expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); }); + it("explains browser WebSocket security failures as insecure context", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: + "Browser refused the Gateway WebSocket for security reasons. Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789.", + lastErrorCode: "BROWSER_WEBSOCKET_SECURITY_ERROR", + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("insecure-context"); + expect(feedback?.rawError).toContain("Use wss://"); + expect(feedback?.rawError).toContain("http://127.0.0.1:18789"); + expect(feedback?.steps.join(" ")).toContain("Tailscale Serve"); + expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); + }); + + it("keeps generic browser WebSocket constructor failures on the network path", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "Could not create the Gateway WebSocket: constructor failed", + lastErrorCode: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + hasToken: false, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("network"); + expect(feedback?.steps.join(" ")).toContain("WebSocket URL"); + }); + it("explains browser origin rejections", () => { const feedback = resolveLoginFailureFeedback({ connected: false, diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index ecc184ef7f7d..d3f759c30da1 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -25,7 +25,10 @@ const AUTH_FAILURE_CODES = new Set([ ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, ]); +const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR"; + const INSECURE_CONTEXT_CODES = new Set([ + BROWSER_WEBSOCKET_SECURITY_ERROR_CODE, ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, ]); diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index 33500d1dae63..a806ec56727b 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -4,6 +4,7 @@ import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connec import { resolveAuthHintKind, resolvePairingHint, + shouldShowInsecureContextHint, shouldShowPairingHint, } from "./overview-hints.ts"; @@ -107,3 +108,25 @@ describe("resolveAuthHintKind", () => { ).toBe("failed"); }); }); + +describe("shouldShowInsecureContextHint", () => { + it("returns true for browser WebSocket security errors", () => { + expect( + shouldShowInsecureContextHint( + false, + "Browser refused the Gateway WebSocket for security reasons.", + "BROWSER_WEBSOCKET_SECURITY_ERROR", + ), + ).toBe(true); + }); + + it("does not treat generic WebSocket constructor errors as insecure context", () => { + expect( + shouldShowInsecureContextHint( + false, + "Could not create the Gateway WebSocket: constructor failed", + "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", + ), + ).toBe(false); + }); +});