fix(pairing): preserve mixed-role node scopes

This commit is contained in:
Ayaan Zaidi 2026-04-03 15:51:08 +05:30 committed by Peter Steinberger
parent a42f000b53
commit 0891253012
2 changed files with 58 additions and 6 deletions

View File

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

View File

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