mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(gateway): stop stale device token reconnect loops
This commit is contained in:
@@ -890,6 +890,36 @@ describe("GatewayClient connect auth payload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
|
||||
loadDeviceAuthTokenMock.mockReturnValue({
|
||||
token: "stored-device-token",
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
const onReconnectPaused = vi.fn();
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
onReconnectPaused,
|
||||
});
|
||||
|
||||
const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
|
||||
expect(firstConnect.params?.auth?.token).toBe("stored-device-token");
|
||||
await expectNoReconnectAfterConnectFailure({
|
||||
client,
|
||||
firstWs: ws1,
|
||||
connectId: firstConnect.id,
|
||||
failureDetails: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
|
||||
});
|
||||
expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({
|
||||
deviceId: expect.any(String),
|
||||
role: "operator",
|
||||
});
|
||||
expect(onReconnectPaused).toHaveBeenCalledWith({
|
||||
code: 1008,
|
||||
reason: "connect failed",
|
||||
detailCode: "AUTH_DEVICE_TOKEN_MISMATCH",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-reconnect on token mismatch when retry is not trusted", async () => {
|
||||
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
|
||||
const client = new GatewayClient({
|
||||
|
||||
@@ -594,6 +594,23 @@ export class GatewayClient {
|
||||
resolvedDeviceToken,
|
||||
storedToken: storedToken ?? undefined,
|
||||
});
|
||||
if (
|
||||
this.opts.deviceIdentity &&
|
||||
usingStoredDeviceToken &&
|
||||
err instanceof GatewayClientRequestError &&
|
||||
readConnectErrorDetailCode(err.details) ===
|
||||
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
|
||||
) {
|
||||
const deviceId = this.opts.deviceIdentity.deviceId;
|
||||
try {
|
||||
clearDeviceAuthToken({ deviceId, role });
|
||||
logDebug(`cleared stale device-auth token for device ${deviceId}`);
|
||||
} catch (clearErr) {
|
||||
logDebug(
|
||||
`failed clearing stale device-auth token for device ${deviceId}: ${String(clearErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (shouldRetryWithDeviceToken) {
|
||||
this.pendingDeviceTokenRetry = true;
|
||||
this.deviceTokenRetryBudgetUsed = true;
|
||||
@@ -653,6 +670,7 @@ export class GatewayClient {
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
|
||||
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
|
||||
detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
|
||||
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
|
||||
|
||||
@@ -45,6 +45,12 @@ describe("isNonRecoverableAuthError", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks reconnect for AUTH_DEVICE_TOKEN_MISMATCH", () => {
|
||||
expect(
|
||||
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reconnect for PAIRING_REQUIRED", () => {
|
||||
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe(
|
||||
true,
|
||||
|
||||
@@ -515,6 +515,36 @@ describe("GatewayBrowserClient", () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const client = new GatewayBrowserClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
});
|
||||
|
||||
const { ws, connectFrame } = await startConnect(client);
|
||||
expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
|
||||
|
||||
ws.emitMessage({
|
||||
type: "res",
|
||||
id: connectFrame.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unauthorized",
|
||||
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
|
||||
},
|
||||
});
|
||||
await expectSocketClosed(ws);
|
||||
ws.emitClose(4008, "connect failed");
|
||||
|
||||
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })).toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(wsInstances).toHaveLength(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRetryWithDeviceToken", () => {
|
||||
|
||||
@@ -86,6 +86,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
|
||||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
|
||||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
|
||||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
||||
code === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
|
||||
code === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
|
||||
code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
|
||||
code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
|
||||
@@ -519,8 +520,12 @@ export class GatewayBrowserClient {
|
||||
} else {
|
||||
this.pendingConnectError = undefined;
|
||||
}
|
||||
const usedStoredDeviceToken =
|
||||
Boolean(plan.selectedAuth.storedToken) &&
|
||||
(plan.selectedAuth.resolvedDeviceToken === plan.selectedAuth.storedToken ||
|
||||
plan.selectedAuth.authDeviceToken === plan.selectedAuth.storedToken);
|
||||
if (
|
||||
plan.selectedAuth.canFallbackToShared &&
|
||||
usedStoredDeviceToken &&
|
||||
plan.deviceIdentity &&
|
||||
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user