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 1ea05289b1
This commit is contained in:
Val Alexander
2026-05-11 07:40:47 -05:00
committed by GitHub
parent 39348e9a93
commit 96b672c54d
6 changed files with 217 additions and 1 deletions
+1
View File
@@ -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.
+81
View File
@@ -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({
+79 -1
View File
@@ -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<ReturnType<typeof loadOrCreateDeviceIdentity>> | 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));
+30
View File
@@ -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,
+3
View File
@@ -25,7 +25,10 @@ const AUTH_FAILURE_CODES = new Set<string>([
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
]);
const BROWSER_WEBSOCKET_SECURITY_ERROR_CODE = "BROWSER_WEBSOCKET_SECURITY_ERROR";
const INSECURE_CONTEXT_CODES = new Set<string>([
BROWSER_WEBSOCKET_SECURITY_ERROR_CODE,
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
]);
+23
View File
@@ -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);
});
});