From 6309b1da6c2e1e3eaaf514b8a41ac83436e56e12 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:57:45 -0500 Subject: [PATCH] Gateway: preserve interactive pairing visibility on supersede --- src/infra/device-pairing.test.ts | 30 ++++++++++++++++++++++++++++++ src/infra/device-pairing.ts | 19 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index b1805145cf8..4f914a51746 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -162,6 +162,36 @@ describe("device pairing tokens", () => { expect(paired?.scopes).toEqual(["operator.read", "operator.write"]); }); + test("keeps superseded requests interactive when an existing pending request is interactive", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const first = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "node", + scopes: [], + silent: false, + }, + baseDir, + ); + expect(first.request.silent).toBe(false); + + const second = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.read"], + silent: true, + }, + baseDir, + ); + + expect(second.created).toBe(true); + expect(second.request.requestId).not.toBe(first.request.requestId); + expect(second.request.silent).toBe(false); + }); + test("rejects bootstrap token replay before pending scope escalation can be approved", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); const issued = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index b51ae0db67a..619e88974c9 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -236,6 +236,15 @@ function refreshPendingDevicePairingRequest( }; } +function resolveSupersededPendingSilent(params: { + existing: readonly DevicePairingPendingRequest[]; + incomingSilent: boolean | undefined; +}): boolean { + return Boolean( + params.incomingSilent && params.existing.every((pending) => pending.silent === true), + ); +} + function buildPendingDevicePairingRequest(params: { requestId?: string; deviceId: string; @@ -394,7 +403,15 @@ export async function requestDevicePairing( const superseded = buildPendingDevicePairingRequest({ deviceId, isRepair, - req, + req: { + ...req, + // Preserve interactive visibility when superseding pending requests: + // if any previous pending request was interactive, keep this one interactive. + silent: resolveSupersededPendingSilent({ + existing: pendingForDevice, + incomingSilent: req.silent, + }), + }, }); state.pendingById[superseded.requestId] = superseded; await persistState(state, baseDir);