fix(gateway): clear pairing state on device token mismatch (#22071)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ad38d1a529
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano 2026-02-20 18:21:13 +00:00 committed by GitHub
parent 094dbdaf2b
commit 5dd304d1c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 73 additions and 6 deletions

View File

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

View File

@ -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<typeof import("../infra/device-pairing.js")>();
return {
...actual,
clearDevicePairing: (...args: unknown[]) => clearDevicePairingMock(...args),
};
});
vi.mock("../logger.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../logger.js")>();
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();
});

View File

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

View File

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

View File

@ -620,3 +620,16 @@ export async function revokeDeviceToken(params: {
return entry;
});
}
export async function clearDevicePairing(deviceId: string, baseDir?: string): Promise<boolean> {
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;
});
}