mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user