mirror of https://github.com/openclaw/openclaw.git
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:
parent
19edeb1aeb
commit
e5fe818a74
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
Loading…
Reference in New Issue