diff --git a/CHANGELOG.md b/CHANGELOG.md index d733308d2e6d..8338212dc7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - **Plugin install diagnostics:** suppress the misleading hook-pack fallback after plugin install failures only when the hook manifest is absent, while preserving actionable malformed hook-pack errors. (#100554) Thanks @vincentkoc. - **Config validation diagnostics:** emit each unchanged sanitized validation-warning payload once per config path, reset deduplication after a clean validation, and preserve the warning fingerprint across transient invalid reads and failed refreshes. (#100569, #25574) Thanks @vincentkoc. - **Config size-drop guard:** compare writes against canonical bytes for parseable object configs instead of raw BOM and indentation overhead, while preserving raw audit telemetry and the conservative malformed-input fallback. (#100591, #71865) Thanks @vincentkoc. +- **Control UI protocol mismatches:** stop automatic reconnect loops after incompatible Gateway and Control UI versions while preserving the actionable mismatch error. (#98414) Thanks @haruaiclone-droid. - **Control UI coalesced updates:** show a clear queued-restart completion banner when an update joins an already-running Gateway restart. (#93082) Thanks @goutamadwant. - **Control UI connection errors:** preserve structured pairing and authentication failures for pending RPC callers while keeping generic disconnect behavior unchanged. (#54758) Thanks @ruanrrn. - **TUI startup status:** show `starting up` during post-connect initialization without overwriting active-run or reconnect state. (#93999) Thanks @ml12580. diff --git a/src/gateway/reconnect-gating.test.ts b/src/gateway/reconnect-gating.test.ts index 35dafebd4986..429e9e48a2be 100644 --- a/src/gateway/reconnect-gating.test.ts +++ b/src/gateway/reconnect-gating.test.ts @@ -1,75 +1,73 @@ // Reconnect gating tests keep UI reconnect policy aligned with gateway protocol -// auth error details that cannot recover by retrying. +// failures that cannot recover by retrying. import { describe, expect, it } from "vitest"; import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; -import { type GatewayErrorInfo, isNonRecoverableAuthError } from "../../ui/src/api/gateway.ts"; +import { type GatewayErrorInfo, isNonRecoverableConnectError } from "../../ui/src/api/gateway.ts"; function makeError(detailCode: string): GatewayErrorInfo { return { code: "connect_failed", message: "auth failed", details: { code: detailCode } }; } -describe("isNonRecoverableAuthError", () => { +describe("isNonRecoverableConnectError", () => { it("returns false for undefined error (normal disconnect)", () => { - expect(isNonRecoverableAuthError(undefined)).toBe(false); + expect(isNonRecoverableConnectError(undefined)).toBe(false); }); it("returns false for errors without detail codes (network issues)", () => { - expect(isNonRecoverableAuthError({ code: "connect_failed", message: "timeout" })).toBe(false); + expect(isNonRecoverableConnectError({})).toBe(false); }); it("blocks reconnect for AUTH_TOKEN_MISSING (misconfigured client)", () => { - expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISSING))).toBe( - true, - ); + expect( + isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISSING)), + ).toBe(true); }); it("blocks reconnect for AUTH_BOOTSTRAP_TOKEN_INVALID", () => { expect( - isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID)), + isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID)), ).toBe(true); }); it("blocks reconnect for AUTH_PASSWORD_MISSING", () => { expect( - isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)), + isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)), ).toBe(true); }); it("blocks reconnect for AUTH_PASSWORD_MISMATCH (wrong password won't self-correct)", () => { expect( - isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH)), + isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH)), ).toBe(true); }); it("blocks reconnect for AUTH_RATE_LIMITED (reconnecting burns more slots)", () => { - expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_RATE_LIMITED))).toBe( + expect(isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_RATE_LIMITED))).toBe( true, ); }); it("blocks reconnect for AUTH_DEVICE_TOKEN_MISMATCH", () => { expect( - isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)), + isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)), ).toBe(true); }); it("blocks reconnect for AUTH_SCOPE_MISMATCH", () => { - expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH))).toBe( - true, - ); + expect( + isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH)), + ).toBe(true); }); it("blocks reconnect for PAIRING_REQUIRED", () => { - expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe( + expect(isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe( true, ); }); it("allows reconnect for PAIRING_REQUIRED when retry hints keep reconnect active", () => { expect( - isNonRecoverableAuthError({ - code: "connect_failed", - message: "auth failed", + isNonRecoverableConnectError({ details: { code: ConnectErrorDetailCodes.PAIRING_REQUIRED, reason: "not-paired", @@ -83,12 +81,18 @@ describe("isNonRecoverableAuthError", () => { it("allows reconnect for AUTH_TOKEN_MISMATCH (device-token fallback flow)", () => { // Browser client can queue a single trusted-device retry after shared token mismatch. // Blocking reconnect on mismatch here would skip that bounded recovery attempt. - expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH))).toBe( - false, + expect( + isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH)), + ).toBe(false); + }); + + it("blocks reconnect for PROTOCOL_MISMATCH", () => { + expect(isNonRecoverableConnectError(makeError(ConnectErrorDetailCodes.PROTOCOL_MISMATCH))).toBe( + true, ); }); it("allows reconnect for unrecognized detail codes (future-proof)", () => { - expect(isNonRecoverableAuthError(makeError("SOME_FUTURE_CODE"))).toBe(false); + expect(isNonRecoverableConnectError(makeError("SOME_FUTURE_CODE"))).toBe(false); }); }); diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index 9052bfec9645..114d69fde0e9 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, @@ -103,6 +104,8 @@ const { CONTROL_UI_OPERATOR_SCOPES, GatewayBrowserClient, GatewayRequestError, + isNonRecoverableConnectError, + resolveGatewayErrorDetailCode, shouldRetryWithDeviceToken, } = await import("./gateway.ts"); @@ -388,6 +391,8 @@ describe("GatewayBrowserClient", () => { }); expect(error.message).toBe(`protocol mismatch: Control UI v${PROTOCOL_VERSION}`); + expect(resolveGatewayErrorDetailCode(error)).toBe(ConnectErrorDetailCodes.PROTOCOL_MISMATCH); + expect(isNonRecoverableConnectError(error)).toBe(true); }); it("reuses cached device token scopes when connecting from bootstrap handoff", async () => { @@ -1267,6 +1272,35 @@ describe("GatewayBrowserClient", () => { vi.useRealTimers(); }); + it("does not auto-reconnect on PROTOCOL_MISMATCH", async () => { + useNodeFakeTimers(); + + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + }); + + const { ws: ws1, connectFrame: connect } = await startConnect(client); + + ws1.emitMessage({ + type: "res", + id: connect.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "protocol mismatch", + details: { code: "PROTOCOL_MISMATCH" }, + }, + }); + await expectSocketClosed(ws1); + ws1.emitClose(4008, "connect failed"); + + await vi.advanceTimersByTimeAsync(30_000); + expect(wsInstances).toHaveLength(1); + + vi.useRealTimers(); + }); + it("keeps reconnecting on PAIRING_REQUIRED when retry hints keep reconnect active", async () => { useNodeFakeTimers(); localStorage.clear(); diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index b9d63976b97e..10b91b8c80bb 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -67,15 +67,16 @@ export class GatewayRequestError extends Error { readonly retryAfterMs?: number; constructor(error: GatewayErrorInfo) { + const details = enrichProtocolMismatchDetails(error.message, error.details); super( formatConnectErrorMessage({ message: error.message, - details: enrichProtocolMismatchDetails(error.message, error.details), + details, }), ); this.name = "GatewayRequestError"; this.gatewayCode = error.code; - this.details = error.details; + this.details = details; this.retryable = error.retryable === true; this.retryAfterMs = error.retryAfterMs; } @@ -111,14 +112,10 @@ function shouldContinueReconnectForPairingRequired(details: unknown): boolean { } /** - * Auth errors that won't resolve without user action — don't auto-reconnect. - * - * NOTE: AUTH_TOKEN_MISMATCH is intentionally NOT included here because the - * browser client supports a bounded one-time retry with a cached device token - * when the endpoint is trusted. Reconnect suppression for mismatch is handled - * with client state (after retry budget is exhausted). + * Connect failures that cannot recover while client and server state stay unchanged. + * AUTH_TOKEN_MISMATCH stays out: the close handler owns its bounded cached-token retry. */ -export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): boolean { +export function isNonRecoverableConnectError(error: { details?: unknown } | undefined): boolean { if (!error) { return false; } @@ -137,6 +134,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || code === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH || code === ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH || + code === ConnectErrorDetailCodes.PROTOCOL_MISMATCH || code === ConnectErrorDetailCodes.PAIRING_REQUIRED || code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED @@ -597,7 +595,7 @@ export class GatewayBrowserClient { !this.closed && (connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH ? this.pendingDeviceTokenRetry - : !isNonRecoverableAuthError(connectError)); + : !isNonRecoverableConnectError(connectError)); this.notifyClose({ code: ev.code, reason, error: connectError, willRetry }); if (willRetry) { this.scheduleReconnect(); diff --git a/ui/src/e2e/login-gate.e2e.test.ts b/ui/src/e2e/login-gate.e2e.test.ts index 0751a1425a89..279390c32915 100644 --- a/ui/src/e2e/login-gate.e2e.test.ts +++ b/ui/src/e2e/login-gate.e2e.test.ts @@ -1,8 +1,10 @@ // Control UI tests cover the responsive disconnected login gate. import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; import { canRunPlaywrightChromium, + installMockGateway, resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, type ControlUiE2eServer, @@ -78,6 +80,32 @@ describeControlUiE2e("Control UI responsive login gate E2E", () => { await server?.close(); }); + it("shows a protocol mismatch without reconnecting", async () => { + const context = await browser.newContext({ viewport: { height: 900, width: 1280 } }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); + + try { + await page.goto(server.baseUrl); + await gateway.waitForRequest("connect"); + await gateway.rejectDeferred("connect", { + code: "INVALID_REQUEST", + message: "protocol mismatch", + details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH }, + }); + + const failure = page.locator(".login-gate__failure-summary"); + await failure.waitFor({ timeout: 10_000 }); + expect((await failure.textContent())?.toLowerCase()).toContain( + "supported connection protocol", + ); + await page.waitForTimeout(1_600); + expect(await gateway.getRequests("connect")).toHaveLength(1); + } finally { + await closeContext(context); + } + }); + it("keeps mobile controls compact, touchable, and keyboard-friendly", async () => { const context = await browser.newContext({ hasTouch: true,