fix: stop reconnecting on protocol mismatch (#98414)

* fix: stop reconnecting on protocol mismatch

* fix(ui): stop protocol mismatch reconnects

* test(ui): assert protocol mismatch recovery copy

* test(ui): count gateway reconnect attempts

* test(gateway): keep detail-less reconnect case typed

* test(gateway): keep pairing retry fixture typed

---------

Co-authored-by: haruaiclone-droid <281899875+haruaiclone-droid@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
haruai
2026-07-06 15:43:33 +09:00
committed by GitHub
parent 5d9cec16f3
commit ead92d7078
5 changed files with 98 additions and 33 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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();

View File

@@ -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();

View File

@@ -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,