fix(pairing): clear stale requests on device removal (#70239)

* fix(pairing): clear stale requests on device removal

* docs(changelog): note pairing stale request cleanup
This commit is contained in:
Devin Robison
2026-04-22 10:05:05 -06:00
committed by GitHub
parent 81e0022b4d
commit dd46783c34
3 changed files with 46 additions and 0 deletions

View File

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

View File

@@ -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"]);

View File

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