Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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.
Expand Down
50 changes: 27 additions & 23 deletions src/gateway/reconnect-gating.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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);
});
});
34 changes: 34 additions & 0 deletions ui/src/api/gateway.node.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -103,6 +104,8 @@ const {
CONTROL_UI_OPERATOR_SCOPES,
GatewayBrowserClient,
GatewayRequestError,
isNonRecoverableConnectError,
resolveGatewayErrorDetailCode,
shouldRetryWithDeviceToken,
} = await import("./gateway.ts");

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 8 additions & 10 deletions ui/src/api/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions ui/src/e2e/login-gate.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading