diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a3336cc67..f399942b04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek. - Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek. - macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit. +- Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky. - Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson. - Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn. diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 4481d94645d..b86d66bd9ba 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -4,6 +4,7 @@ import type { DeviceIdentity } from "../infra/device-identity.js"; const wsInstances = vi.hoisted((): MockWebSocket[] => []); const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); +const clearDevicePairingMock = vi.hoisted(() => vi.fn()); const logDebugMock = vi.hoisted(() => vi.fn()); type WsEvent = "open" | "message" | "close" | "error"; @@ -68,6 +69,14 @@ vi.mock("../infra/device-auth-store.js", async (importOriginal) => { }; }); +vi.mock("../infra/device-pairing.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + clearDevicePairing: (...args: unknown[]) => clearDevicePairingMock(...args), + }; +}); + vi.mock("../logger.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -161,6 +170,8 @@ describe("GatewayClient close handling", () => { beforeEach(() => { wsInstances.length = 0; clearDeviceAuthTokenMock.mockReset(); + clearDevicePairingMock.mockReset(); + clearDevicePairingMock.mockResolvedValue(true); logDebugMock.mockReset(); }); @@ -184,6 +195,7 @@ describe("GatewayClient close handling", () => { ); expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ deviceId: "dev-1", role: "operator" }); + expect(clearDevicePairingMock).toHaveBeenCalledWith("dev-1"); expect(onClose).toHaveBeenCalledWith( 1008, "unauthorized: DEVICE token mismatch (rotate/reissue device token)", @@ -215,6 +227,34 @@ describe("GatewayClient close handling", () => { expect(logDebugMock).toHaveBeenCalledWith( expect.stringContaining("failed clearing stale device-auth token"), ); + expect(clearDevicePairingMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch"); + client.stop(); + }); + + it("does not break close flow when pairing clear rejects", async () => { + clearDevicePairingMock.mockRejectedValue(new Error("pairing store unavailable")); + const onClose = vi.fn(); + const identity: DeviceIdentity = { + deviceId: "dev-3", + privateKeyPem: "private-key", + publicKeyPem: "public-key", + }; + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + deviceIdentity: identity, + onClose, + }); + + client.start(); + expect(() => { + getLatestWs().emitClose(1008, "unauthorized: device token mismatch"); + }).not.toThrow(); + + await Promise.resolve(); + expect(logDebugMock).toHaveBeenCalledWith( + expect.stringContaining("failed clearing stale device pairing"), + ); expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch"); client.stop(); }); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 270ce2633e2..05f91e78b39 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -11,6 +11,7 @@ import { publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; +import { clearDevicePairing } from "../infra/device-pairing.js"; import { normalizeFingerprint } from "../infra/tls/fingerprint.js"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; @@ -175,21 +176,23 @@ export class GatewayClient { this.ws.on("close", (code, reason) => { const reasonText = rawDataToString(reason); this.ws = null; - // If closed due to device token mismatch, clear the stored token so next attempt can get a fresh one + // If closed due to device token mismatch, clear the stored token and pairing so next attempt can get a fresh one if ( code === 1008 && reasonText.toLowerCase().includes("device token mismatch") && this.opts.deviceIdentity ) { + const deviceId = this.opts.deviceIdentity.deviceId; const role = this.opts.role ?? "operator"; try { - clearDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role }); - logDebug( - `cleared stale device-auth token for device ${this.opts.deviceIdentity.deviceId}`, - ); + clearDeviceAuthToken({ deviceId, role }); + void clearDevicePairing(deviceId).catch((err) => { + logDebug(`failed clearing stale device pairing for device ${deviceId}: ${String(err)}`); + }); + logDebug(`cleared stale device-auth token for device ${deviceId}`); } catch (err) { logDebug( - `failed clearing stale device-auth token for device ${this.opts.deviceIdentity.deviceId}: ${String(err)}`, + `failed clearing stale device-auth token for device ${deviceId}: ${String(err)}`, ); } } diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 11119b2f0ab..a3cd0b0e8ef 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { approveDevicePairing, + clearDevicePairing, getPairedDevice, removePairedDevice, requestDevicePairing, @@ -221,4 +222,13 @@ describe("device pairing tokens", () => { await expect(removePairedDevice("device-1", baseDir)).resolves.toBeNull(); }); + + test("clears paired device state by device id", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + await setupPairedOperatorDevice(baseDir, ["operator.read"]); + + await expect(clearDevicePairing("device-1", baseDir)).resolves.toBe(true); + await expect(getPairedDevice("device-1", baseDir)).resolves.toBeNull(); + await expect(clearDevicePairing("device-1", baseDir)).resolves.toBe(false); + }); }); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 5a2caab853d..1bee5d34260 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -620,3 +620,16 @@ export async function revokeDeviceToken(params: { return entry; }); } + +export async function clearDevicePairing(deviceId: string, baseDir?: string): Promise { + return await withLock(async () => { + const state = await loadState(baseDir); + const normalizedId = normalizeDeviceId(deviceId); + if (!state.pairedByDeviceId[normalizedId]) { + return false; + } + delete state.pairedByDeviceId[normalizedId]; + await persistState(state, baseDir); + return true; + }); +}