fix(gateway): stop stale device token reconnect loops · openclaw/openclaw@885806d
steipete
·
2026-04-28
·
via Recent Commits to openclaw:main
| Original file line number | Diff line number | Diff line change |
|---|
@@ -890,6 +890,36 @@ describe("GatewayClient connect auth payload", () => {
|
890 | 890 | }); |
891 | 891 | }); |
892 | 892 | |
| 893 | +it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => { |
| 894 | +loadDeviceAuthTokenMock.mockReturnValue({ |
| 895 | +token: "stored-device-token", |
| 896 | +scopes: ["operator.read"], |
| 897 | +}); |
| 898 | +const onReconnectPaused = vi.fn(); |
| 899 | +const client = new GatewayClient({ |
| 900 | +url: "ws://127.0.0.1:18789", |
| 901 | + onReconnectPaused, |
| 902 | +}); |
| 903 | + |
| 904 | +const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client }); |
| 905 | +expect(firstConnect.params?.auth?.token).toBe("stored-device-token"); |
| 906 | +await expectNoReconnectAfterConnectFailure({ |
| 907 | + client, |
| 908 | +firstWs: ws1, |
| 909 | +connectId: firstConnect.id, |
| 910 | +failureDetails: { code: "AUTH_DEVICE_TOKEN_MISMATCH" }, |
| 911 | +}); |
| 912 | +expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ |
| 913 | +deviceId: expect.any(String), |
| 914 | +role: "operator", |
| 915 | +}); |
| 916 | +expect(onReconnectPaused).toHaveBeenCalledWith({ |
| 917 | +code: 1008, |
| 918 | +reason: "connect failed", |
| 919 | +detailCode: "AUTH_DEVICE_TOKEN_MISMATCH", |
| 920 | +}); |
| 921 | +}); |
| 922 | + |
893 | 923 | it("does not auto-reconnect on token mismatch when retry is not trusted", async () => { |
894 | 924 | loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); |
895 | 925 | const client = new GatewayClient({ |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -594,6 +594,23 @@ export class GatewayClient {
|
594 | 594 | resolvedDeviceToken, |
595 | 595 | storedToken: storedToken ?? undefined, |
596 | 596 | }); |
| 597 | +if ( |
| 598 | +this.opts.deviceIdentity && |
| 599 | +usingStoredDeviceToken && |
| 600 | +err instanceof GatewayClientRequestError && |
| 601 | +readConnectErrorDetailCode(err.details) === |
| 602 | +ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH |
| 603 | +) { |
| 604 | +const deviceId = this.opts.deviceIdentity.deviceId; |
| 605 | +try { |
| 606 | +clearDeviceAuthToken({ deviceId, role }); |
| 607 | +logDebug(`cleared stale device-auth token for device ${deviceId}`); |
| 608 | +} catch (clearErr) { |
| 609 | +logDebug( |
| 610 | +`failed clearing stale device-auth token for device ${deviceId}: ${String(clearErr)}`, |
| 611 | +); |
| 612 | +} |
| 613 | +} |
597 | 614 | if (shouldRetryWithDeviceToken) { |
598 | 615 | this.pendingDeviceTokenRetry = true; |
599 | 616 | this.deviceTokenRetryBudgetUsed = true; |
@@ -653,6 +670,7 @@ export class GatewayClient {
|
653 | 670 | detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || |
654 | 671 | detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || |
655 | 672 | detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || |
| 673 | +detailCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH || |
656 | 674 | detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED || |
657 | 675 | detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || |
658 | 676 | detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -45,6 +45,12 @@ describe("isNonRecoverableAuthError", () => {
|
45 | 45 | ); |
46 | 46 | }); |
47 | 47 | |
| 48 | +it("blocks reconnect for AUTH_DEVICE_TOKEN_MISMATCH", () => { |
| 49 | +expect( |
| 50 | +isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)), |
| 51 | +).toBe(true); |
| 52 | +}); |
| 53 | + |
48 | 54 | it("blocks reconnect for PAIRING_REQUIRED", () => { |
49 | 55 | expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe( |
50 | 56 | true, |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -515,6 +515,36 @@ describe("GatewayBrowserClient", () => {
|
515 | 515 | |
516 | 516 | vi.useRealTimers(); |
517 | 517 | }); |
| 518 | + |
| 519 | +it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => { |
| 520 | +vi.useFakeTimers(); |
| 521 | + |
| 522 | +const client = new GatewayBrowserClient({ |
| 523 | +url: "ws://127.0.0.1:18789", |
| 524 | +}); |
| 525 | + |
| 526 | +const { ws, connectFrame } = await startConnect(client); |
| 527 | +expect(connectFrame.params?.auth?.token).toBe("stored-device-token"); |
| 528 | + |
| 529 | +ws.emitMessage({ |
| 530 | +type: "res", |
| 531 | +id: connectFrame.id, |
| 532 | +ok: false, |
| 533 | +error: { |
| 534 | +code: "INVALID_REQUEST", |
| 535 | +message: "unauthorized", |
| 536 | +details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" }, |
| 537 | +}, |
| 538 | +}); |
| 539 | +await expectSocketClosed(ws); |
| 540 | +ws.emitClose(4008, "connect failed"); |
| 541 | + |
| 542 | +expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })).toBeNull(); |
| 543 | +await vi.advanceTimersByTimeAsync(30_000); |
| 544 | +expect(wsInstances).toHaveLength(1); |
| 545 | + |
| 546 | +vi.useRealTimers(); |
| 547 | +}); |
518 | 548 | }); |
519 | 549 | |
520 | 550 | describe("shouldRetryWithDeviceToken", () => { |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -86,6 +86,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
|
86 | 86 | code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || |
87 | 87 | code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || |
88 | 88 | code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || |
| 89 | +code === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH || |
89 | 90 | code === ConnectErrorDetailCodes.PAIRING_REQUIRED || |
90 | 91 | code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || |
91 | 92 | code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED |
@@ -519,8 +520,12 @@ export class GatewayBrowserClient {
|
519 | 520 | } else { |
520 | 521 | this.pendingConnectError = undefined; |
521 | 522 | } |
| 523 | +const usedStoredDeviceToken = |
| 524 | +Boolean(plan.selectedAuth.storedToken) && |
| 525 | +(plan.selectedAuth.resolvedDeviceToken === plan.selectedAuth.storedToken || |
| 526 | +plan.selectedAuth.authDeviceToken === plan.selectedAuth.storedToken); |
522 | 527 | if ( |
523 | | -plan.selectedAuth.canFallbackToShared && |
| 528 | +usedStoredDeviceToken && |
524 | 529 | plan.deviceIdentity && |
525 | 530 | connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH |
526 | 531 | ) { |
|
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。