diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c14426799c..fb9d2bc1a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - QQBot: add `INTERACTION` intent (`1 << 26`) to the gateway constants and include it in the `FULL_INTENTS` mask so interaction events are received. (#70143) Thanks @cxyhhhhh. - Gateway/restart: preserve one-shot continuation instructions across gateway restarts so agents can resume and reply back to the original chat after reboot. (#63406) Thanks @VACInc. - Gateway/restart: write restart sentinel files atomically so interrupted writes cannot leave a truncated sentinel behind. (#70225) Thanks @obviyus. +- Pairing: remove stale pending requests for a device when that paired device is deleted, so an old repair approval cannot recreate the removed device from leftover state. ## 2026.4.21 diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 8817ece9cb9..a6845eb32d7 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -1013,6 +1013,46 @@ describe("device pairing tokens", () => { await expect(removePairedDevice("device-1", baseDir)).resolves.toBeNull(); }); + test("removing a paired device clears pending requests for that device only", async () => { + const baseDir = await makeDevicePairingDir(); + await setupPairedOperatorDevice(baseDir, ["operator.read"]); + + const staleRepair = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1-rotated", + role: "operator", + scopes: ["operator.read"], + }, + baseDir, + ); + const otherPending = await requestDevicePairing( + { + deviceId: "device-2", + publicKey: "public-key-2", + role: "node", + scopes: [], + }, + baseDir, + ); + + await expect(removePairedDevice("device-1", baseDir)).resolves.toEqual({ + deviceId: "device-1", + }); + + const pending = (await listDevicePairing(baseDir)).pending; + expect(pending.map((entry) => entry.requestId)).not.toContain(staleRepair.request.requestId); + expect(pending.map((entry) => entry.requestId)).toContain(otherPending.request.requestId); + await expect( + approveDevicePairing( + staleRepair.request.requestId, + { callerScopes: ["operator.read"] }, + baseDir, + ), + ).resolves.toBeNull(); + await expect(getPairedDevice("device-1", baseDir)).resolves.toBeNull(); + }); + test("clears paired device state by device id", async () => { const baseDir = await makeDevicePairingDir(); await setupPairedOperatorDevice(baseDir, ["operator.read"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 33def7e4136..a8a06f064a3 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -758,6 +758,11 @@ export async function removePairedDevice( return null; } delete state.pairedByDeviceId[normalized]; + for (const [requestId, pending] of Object.entries(state.pendingById)) { + if (pending.deviceId === normalized) { + delete state.pendingById[requestId]; + } + } await persistState(state, baseDir); return { deviceId: normalized }; });