diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 0a044c64f97..024a109d082 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -265,6 +265,52 @@ describe("device pairing tokens", () => { ).resolves.toEqual({ ok: true }); }); + test("preserves existing non-operator scopes during operator-only mixed-role repairs", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const initial = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "node", + scopes: ["node.exec"], + }, + baseDir, + ); + await expect(approveDevicePairing(initial.request.requestId, baseDir)).resolves.toMatchObject({ + status: "approved", + requestId: initial.request.requestId, + }); + + const repair = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + roles: ["node", "operator"], + scopes: ["operator.read"], + }, + baseDir, + ); + await expect( + approveDevicePairing(repair.request.requestId, { callerScopes: ["operator.read"] }, baseDir), + ).resolves.toMatchObject({ + status: "approved", + requestId: repair.request.requestId, + }); + + const paired = await getPairedDevice("device-1", baseDir); + expect(paired?.tokens?.node?.scopes).toEqual(["node.exec"]); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); + await expect( + verifyDeviceToken({ + deviceId: "device-1", + token: requireToken(paired?.tokens?.node?.token), + role: "node", + scopes: ["node.exec"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + }); + 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( diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index e39bfe0c9f9..56cde1657a1 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -344,6 +344,14 @@ function buildDeviceAuthToken(params: { }; } +function resolveRoleScopedDeviceTokenScopes(role: string, scopes: string[] | undefined): string[] { + const normalized = normalizeDeviceAuthScopes(scopes); + if (role === "operator") { + return normalized.filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX)); + } + return normalized.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX)); +} + function resolveApprovedTokenScopes(params: { role: string; pending: DevicePairingPendingRequest; @@ -351,14 +359,12 @@ function resolveApprovedTokenScopes(params: { approvedScopes?: string[]; existing?: PairedDevice; }): string[] { - const requestedScopes = normalizeDeviceAuthScopes(params.pending.scopes); + const requestedScopes = resolveRoleScopedDeviceTokenScopes(params.role, params.pending.scopes); if (requestedScopes.length > 0) { - if (params.role === "operator") { - return requestedScopes; - } - return requestedScopes.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX)); + return requestedScopes; } - return normalizeDeviceAuthScopes( + return resolveRoleScopedDeviceTokenScopes( + params.role, params.existingToken?.scopes ?? params.approvedScopes ?? params.existing?.approvedScopes ??