fix(gateway/ui): restore control-ui auth bypass and classify connect failures (#45512)

Merged via squash.

Prepared head SHA: 42b5595ede
Co-authored-by: sallyom <11166065+sallyom@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
This commit is contained in:
Sally O'Malley 2026-03-13 21:13:35 -04:00 committed by GitHub
parent 19edeb1aeb
commit e5fe818a74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 337 additions and 58 deletions

View File

@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
- Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh.
- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
- Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.
- Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.
## 2026.3.12

View File

@ -14,6 +14,7 @@ export const ConnectErrorDetailCodes = {
AUTH_TAILSCALE_PROXY_MISSING: "AUTH_TAILSCALE_PROXY_MISSING",
AUTH_TAILSCALE_WHOIS_FAILED: "AUTH_TAILSCALE_WHOIS_FAILED",
AUTH_TAILSCALE_IDENTITY_MISMATCH: "AUTH_TAILSCALE_IDENTITY_MISMATCH",
CONTROL_UI_ORIGIN_NOT_ALLOWED: "CONTROL_UI_ORIGIN_NOT_ALLOWED",
CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED",
DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID",

View File

@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { ConnectErrorDetailCodes } from "../gateway/protocol/connect-error-details.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
@ -123,6 +124,9 @@ describe("gateway auth browser hardening", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("origin not allowed");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED,
);
});
});
@ -188,6 +192,9 @@ describe("gateway auth browser hardening", () => {
expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok");
} else {
expect(res.error?.message ?? "").toContain(expectedMessage ?? "");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED,
);
}
} finally {
ws.close();
@ -207,6 +214,9 @@ describe("gateway auth browser hardening", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("origin not allowed");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED,
);
} finally {
ws.close();
}

View File

@ -169,9 +169,47 @@ describe("ws connect policy", () => {
isLocalClient: false,
}).kind,
).toBe("allow");
const bypass = resolveControlUiAuthPolicy({
isControlUi: true,
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
deviceRaw: null,
});
expect(
evaluateMissingDeviceIdentity({
hasDeviceIdentity: false,
role: "operator",
isControlUi: true,
controlUiAuthPolicy: bypass,
trustedProxyAuthOk: false,
sharedAuthOk: false,
authOk: false,
hasSharedAuth: false,
isLocalClient: false,
}).kind,
).toBe("allow");
// Regression: dangerouslyDisableDeviceAuth bypass must NOT extend to node-role
// sessions — the break-glass flag is scoped to operator Control UI only.
// A device-less node-role connection must still be rejected even when the flag
// is set, to prevent the flag from being abused to admit unauthorized node
// registrations.
expect(
evaluateMissingDeviceIdentity({
hasDeviceIdentity: false,
role: "node",
isControlUi: true,
controlUiAuthPolicy: bypass,
trustedProxyAuthOk: false,
sharedAuthOk: false,
authOk: false,
hasSharedAuth: false,
isLocalClient: false,
}).kind,
).toBe("reject-device-required");
});
test("pairing bypass requires control-ui bypass + shared auth (or trusted-proxy auth)", () => {
test("dangerouslyDisableDeviceAuth skips pairing for operator control-ui only", () => {
const bypass = resolveControlUiAuthPolicy({
isControlUi: true,
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
@ -182,10 +220,10 @@ describe("ws connect policy", () => {
controlUiConfig: undefined,
deviceRaw: null,
});
expect(shouldSkipControlUiPairing(bypass, true, false)).toBe(true);
expect(shouldSkipControlUiPairing(bypass, false, false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true);
expect(shouldSkipControlUiPairing(bypass, "operator", false)).toBe(true);
expect(shouldSkipControlUiPairing(bypass, "node", false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, "operator", false)).toBe(false);
expect(shouldSkipControlUiPairing(strict, "operator", true)).toBe(true);
});
test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {

View File

@ -34,13 +34,16 @@ export function resolveControlUiAuthPolicy(params: {
export function shouldSkipControlUiPairing(
policy: ControlUiAuthPolicy,
sharedAuthOk: boolean,
role: GatewayRole,
trustedProxyAuthOk = false,
): boolean {
if (trustedProxyAuthOk) {
return true;
}
return policy.allowBypass && sharedAuthOk;
// dangerouslyDisableDeviceAuth is the break-glass path for Control UI
// operators. Keep pairing aligned with the missing-device bypass, including
// open-auth deployments where there is no shared token/password to prove.
return role === "operator" && policy.allowBypass;
}
export function isTrustedProxyControlUiOperatorAuth(params: {
@ -82,6 +85,14 @@ export function evaluateMissingDeviceIdentity(params: {
if (params.isControlUi && params.trustedProxyAuthOk) {
return { kind: "allow" };
}
if (params.isControlUi && params.controlUiAuthPolicy.allowBypass && params.role === "operator") {
// dangerouslyDisableDeviceAuth: true — operator has explicitly opted out of
// device-identity enforcement for this Control UI. Allow for operator-role
// sessions only; node-role sessions must still satisfy device identity so
// that the break-glass flag cannot be abused to admit device-less node
// registrations (see #45405 review).
return { kind: "allow" };
}
if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) {
// Allow localhost Control UI connections when allowInsecureAuth is configured.
// Localhost has no network interception risk, and browser SubtleCrypto

View File

@ -421,7 +421,12 @@ export function attachGatewayWsMessageHandler(params: {
host: requestHost ?? "n/a",
reason: originCheck.reason,
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, {
details: {
code: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED,
reason: originCheck.reason,
},
});
close(1008, truncateCloseReason(errorMessage));
return;
}
@ -676,7 +681,7 @@ export function attachGatewayWsMessageHandler(params: {
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
}) || shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk);
}) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk);
if (device && devicePublicKey && !skipPairing) {
const formatAuditList = (items: string[] | undefined): string => {
if (!items || items.length === 0) {

View File

@ -271,6 +271,48 @@ describe("connectGateway", () => {
expect(host.lastError).toContain("too many failed authentication attempts");
});
it("maps generic fetch failures to actionable device identity 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: "Fetch failed",
details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED },
},
});
expect(host.lastErrorCode).toBe(ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED);
expect(host.lastError).toContain("device identity required");
});
it("maps generic fetch failures to actionable origin 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: "Fetch failed",
details: { code: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED },
},
});
expect(host.lastErrorCode).toBe(ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED);
expect(host.lastError).toContain("origin not allowed");
});
it("preserves specific close errors even when auth detail codes are present", () => {
const host = createHost();

View File

@ -2,7 +2,6 @@ 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 {
@ -14,6 +13,7 @@ import {
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts";
import type { OpenClawApp } from "./app.ts";
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
import { formatConnectError } from "./connect-error.ts";
import { loadAgents } from "./controllers/agents.ts";
import { loadAssistantIdentity } from "./controllers/assistant-identity.ts";
import { loadChatHistory } from "./controllers/chat.ts";
@ -49,20 +49,6 @@ 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;
@ -240,7 +226,11 @@ export function connectGateway(host: GatewayHost) {
if (error?.message) {
host.lastError =
host.lastErrorCode && isGenericBrowserFetchFailure(error.message)
? formatAuthCloseErrorMessage(host.lastErrorCode, error.message)
? formatConnectError({
message: error.message,
details: error.details,
code: error.code,
} as Parameters<typeof formatConnectError>[0])
: error.message;
return;
}

View File

@ -0,0 +1,58 @@
import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js";
import { resolveGatewayErrorDetailCode } from "./gateway.ts";
type ErrorWithMessageAndDetails = {
message?: unknown;
details?: unknown;
};
function normalizeErrorMessage(message: unknown): string {
if (typeof message === "string") {
return message;
}
if (message instanceof Error && typeof message.message === "string") {
return message.message;
}
return "unknown error";
}
function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): string {
const message = normalizeErrorMessage(error.message);
const detailCode = resolveGatewayErrorDetailCode(error);
switch (detailCode) {
case ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH:
return "gateway token mismatch";
case ConnectErrorDetailCodes.AUTH_UNAUTHORIZED:
return "gateway auth failed";
case ConnectErrorDetailCodes.AUTH_RATE_LIMITED:
return "too many failed authentication attempts";
case ConnectErrorDetailCodes.PAIRING_REQUIRED:
return "gateway pairing required";
case ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED:
return "device identity required (use HTTPS/localhost or allow insecure auth explicitly)";
case ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED:
return "origin not allowed (open the Control UI from the gateway host or allow it in gateway.controlUi.allowedOrigins)";
case ConnectErrorDetailCodes.AUTH_TOKEN_MISSING:
return "gateway token missing";
default:
break;
}
const normalized = message.trim().toLowerCase();
if (
normalized === "fetch failed" ||
normalized === "failed to fetch" ||
normalized === "connect failed"
) {
return "gateway connect failed";
}
return message;
}
export function formatConnectError(error: unknown): string {
if (error && typeof error === "object") {
return formatErrorFromMessageAndDetails(error as ErrorWithMessageAndDetails);
}
return normalizeErrorMessage(error);
}

View File

@ -1,5 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { handleChatEvent, loadChatHistory, type ChatEventPayload, type ChatState } from "./chat.ts";
import { GatewayRequestError } from "../gateway.ts";
import {
abortChatRun,
handleChatEvent,
loadChatHistory,
sendChatMessage,
type ChatEventPayload,
type ChatState,
} from "./chat.ts";
function createState(overrides: Partial<ChatState> = {}): ChatState {
return {
@ -536,6 +544,63 @@ describe("loadChatHistory", () => {
});
});
describe("sendChatMessage", () => {
it("formats structured non-auth connect failures for chat send", async () => {
const request = vi.fn().mockRejectedValue(
new GatewayRequestError({
code: "INVALID_REQUEST",
message: "Fetch failed",
details: { code: "CONTROL_UI_ORIGIN_NOT_ALLOWED" },
}),
);
const state = createState({
connected: true,
client: { request } as unknown as ChatState["client"],
});
const result = await sendChatMessage(state, "hello");
expect(result).toBeNull();
expect(state.lastError).toContain("origin not allowed");
expect(state.chatMessages.at(-1)).toMatchObject({
role: "assistant",
content: [
{
type: "text",
text: expect.stringContaining("origin not allowed"),
},
],
});
});
});
describe("abortChatRun", () => {
it("formats structured non-auth connect failures for chat abort", async () => {
// Abort now shares the same structured connect-error formatter as send.
const request = vi.fn().mockRejectedValue(
new GatewayRequestError({
code: "INVALID_REQUEST",
message: "Fetch failed",
details: { code: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED" },
}),
);
const state = createState({
connected: true,
chatRunId: "run-1",
client: { request } as unknown as ChatState["client"],
});
const result = await abortChatRun(state);
expect(result).toBe(false);
expect(request).toHaveBeenCalledWith("chat.abort", {
sessionKey: "main",
runId: "run-1",
});
expect(state.lastError).toContain("device identity required");
});
});
describe("loadChatHistory", () => {
it("filters assistant NO_REPLY messages and keeps user NO_REPLY messages", async () => {
const request = vi.fn().mockResolvedValue({

View File

@ -1,5 +1,6 @@
import { resetToolStream } from "../app-tool-stream.ts";
import { extractText } from "../chat/message-extract.ts";
import { formatConnectError } from "../connect-error.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ChatAttachment } from "../ui-types.ts";
import { generateUUID } from "../uuid.ts";
@ -223,7 +224,7 @@ export async function sendChatMessage(
});
return runId;
} catch (err) {
const error = String(err);
const error = formatConnectError(err);
state.chatRunId = null;
state.chatStream = null;
state.chatStreamStartedAt = null;
@ -254,7 +255,7 @@ export async function abortChatRun(state: ChatState): Promise<boolean> {
);
return true;
} catch (err) {
state.lastError = String(err);
state.lastError = formatConnectError(err);
return false;
}
}

View File

@ -26,6 +26,8 @@ const INSECURE_CONTEXT_CODES = new Set<string>([
ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
]);
type AuthHintKind = "required" | "failed";
/** Whether the overview should show device-pairing guidance for this error. */
export function shouldShowPairingHint(
connected: boolean,
@ -41,30 +43,34 @@ export function shouldShowPairingHint(
return lastError.toLowerCase().includes("pairing required");
}
export function shouldShowAuthHint(
connected: boolean,
lastError: string | null,
lastErrorCode?: string | null,
): boolean {
if (connected || !lastError) {
return false;
/**
* Return the overview auth hint to show, if any.
*
* Keep fallback string matching narrow so generic "connect failed" close reasons
* do not get misclassified as token/password problems.
*/
export function resolveAuthHintKind(params: {
connected: boolean;
lastError: string | null;
lastErrorCode?: string | null;
hasToken: boolean;
hasPassword: boolean;
}): AuthHintKind | null {
if (params.connected || !params.lastError) {
return null;
}
if (lastErrorCode) {
return AUTH_FAILURE_CODES.has(lastErrorCode);
if (params.lastErrorCode) {
if (!AUTH_FAILURE_CODES.has(params.lastErrorCode)) {
return null;
}
return AUTH_REQUIRED_CODES.has(params.lastErrorCode) ? "required" : "failed";
}
const lower = lastError.toLowerCase();
return lower.includes("unauthorized") || lower.includes("connect failed");
}
export function shouldShowAuthRequiredHint(
hasToken: boolean,
hasPassword: boolean,
lastErrorCode?: string | null,
): boolean {
if (lastErrorCode) {
return AUTH_REQUIRED_CODES.has(lastErrorCode);
const lower = params.lastError.toLowerCase();
if (!lower.includes("unauthorized")) {
return null;
}
return !hasToken && !hasPassword;
return !params.hasToken && !params.hasPassword ? "required" : "failed";
}
export function shouldShowInsecureContextHint(

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { shouldShowPairingHint } from "./overview-hints.ts";
import { resolveAuthHintKind, shouldShowPairingHint } from "./overview-hints.ts";
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
@ -37,3 +37,53 @@ describe("shouldShowPairingHint", () => {
).toBe(true);
});
});
describe("resolveAuthHintKind", () => {
it("returns required for structured auth-required codes", () => {
expect(
resolveAuthHintKind({
connected: false,
lastError: "disconnected (4008): connect failed",
lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
hasToken: false,
hasPassword: false,
}),
).toBe("required");
});
it("returns failed for structured auth mismatch codes", () => {
expect(
resolveAuthHintKind({
connected: false,
lastError: "disconnected (4008): connect failed",
lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
hasToken: true,
hasPassword: false,
}),
).toBe("failed");
});
it("does not treat generic connect failures as auth failures", () => {
expect(
resolveAuthHintKind({
connected: false,
lastError: "disconnected (4008): connect failed",
lastErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
hasToken: true,
hasPassword: false,
}),
).toBeNull();
});
it("falls back to unauthorized string matching without structured codes", () => {
expect(
resolveAuthHintKind({
connected: false,
lastError: "disconnected (4008): unauthorized",
lastErrorCode: null,
hasToken: true,
hasPassword: false,
}),
).toBe("failed");
});
});

View File

@ -18,8 +18,7 @@ import { renderOverviewAttention } from "./overview-attention.ts";
import { renderOverviewCards } from "./overview-cards.ts";
import { renderOverviewEventLog } from "./overview-event-log.ts";
import {
shouldShowAuthHint,
shouldShowAuthRequiredHint,
resolveAuthHintKind,
shouldShowInsecureContextHint,
shouldShowPairingHint,
} from "./overview-hints.ts";
@ -103,15 +102,17 @@ export function renderOverview(props: OverviewProps) {
})();
const authHint = (() => {
if (props.connected || !props.lastError) {
const authHintKind = resolveAuthHintKind({
connected: props.connected,
lastError: props.lastError,
lastErrorCode: props.lastErrorCode,
hasToken: Boolean(props.settings.token.trim()),
hasPassword: Boolean(props.password.trim()),
});
if (authHintKind == null) {
return null;
}
if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) {
return null;
}
const hasToken = Boolean(props.settings.token.trim());
const hasPassword = Boolean(props.password.trim());
if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) {
if (authHintKind === "required") {
return html`
<div class="muted" style="margin-top: 8px">
${t("overview.auth.required")}