Skip to content

Commit 39273ee

Browse files
committed
fix(pairing): preserve narrowed token scopes on upgrade
1 parent cde99c3 commit 39273ee

3 files changed

Lines changed: 78 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ Docs: https://docs.openclaw.ai
163163

164164
### Fixes
165165

166+
- Gateway/pairing: preserve deliberately narrowed role-token scopes when approving device scope upgrades instead of regranting the whole approved baseline.
166167
- Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested.
167168
- Codex app-server: close stdio stdin before force-killing the managed app-server, matching Codex single-client shutdown behavior and avoiding unsettled CLI exits after successful runs.
168169
- CLI/Codex: dispose registered agent harnesses during short-lived CLI shutdown so successful Codex-backed `agent --local` runs do not leave app-server child processes alive.

src/infra/device-pairing.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,66 @@ describe("device pairing tokens", () => {
334334
).resolves.toEqual({ ok: true });
335335
});
336336

337+
test("preserves existing operator token scopes when approving a scope upgrade", async () => {
338+
const baseDir = await makeDevicePairingDir();
339+
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
340+
341+
const upgrade = await requestDevicePairing(
342+
{
343+
deviceId: "device-1",
344+
publicKey: "public-key-1",
345+
role: "operator",
346+
scopes: ["operator.write"],
347+
},
348+
baseDir,
349+
);
350+
351+
await expect(
352+
approveDevicePairing(
353+
upgrade.request.requestId,
354+
{ callerScopes: ["operator.read", "operator.write"] },
355+
baseDir,
356+
),
357+
).resolves.toMatchObject({ status: "approved" });
358+
359+
const paired = await getPairedDevice("device-1", baseDir);
360+
expect(paired?.approvedScopes).toEqual(["operator.read", "operator.write"]);
361+
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.write"]);
362+
});
363+
364+
test("does not widen a down-scoped operator token when approving a scope upgrade", async () => {
365+
const baseDir = await makeDevicePairingDir();
366+
await setupPairedOperatorDevice(baseDir, ["operator.read", "operator.write"]);
367+
await overwritePairedOperatorTokenScopes(baseDir, ["operator.read"]);
368+
369+
const upgrade = await requestDevicePairing(
370+
{
371+
deviceId: "device-1",
372+
publicKey: "public-key-1",
373+
role: "operator",
374+
scopes: ["operator.talk.secrets"],
375+
},
376+
baseDir,
377+
);
378+
379+
await expect(
380+
approveDevicePairing(
381+
upgrade.request.requestId,
382+
{ callerScopes: ["operator.read", "operator.talk.secrets", "operator.write"] },
383+
baseDir,
384+
),
385+
).resolves.toMatchObject({ status: "approved" });
386+
387+
const paired = await getPairedDevice("device-1", baseDir);
388+
expect(paired?.approvedScopes).toEqual([
389+
"operator.read",
390+
"operator.write",
391+
"operator.talk.secrets",
392+
]);
393+
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.talk.secrets"]);
394+
expect(paired?.tokens?.operator?.scopes).not.toContain("operator.write");
395+
});
396+
337397
test("preserves requested non-operator scopes on newly minted role tokens", async () => {
338398
const baseDir = await makeDevicePairingDir();
339399
const request = await requestDevicePairing(

src/infra/device-pairing.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -437,9 +437,23 @@ function resolveApprovedTokenScopes(params: {
437437
approvedScopes?: string[];
438438
existing?: PairedDevice;
439439
}): string[] {
440-
const requestedScopes = resolveRoleScopedDeviceTokenScopes(params.role, params.pending.scopes);
441-
if (requestedScopes.length > 0) {
442-
return requestedScopes;
440+
const pendingScopes = resolveRoleScopedDeviceTokenScopes(params.role, params.pending.scopes);
441+
if (pendingScopes.length > 0) {
442+
const approvedBaseline = resolveRoleScopedDeviceTokenScopes(
443+
params.role,
444+
params.existing?.approvedScopes ?? params.existing?.scopes,
445+
);
446+
const requestedScopeDelta =
447+
params.existingToken && approvedBaseline.length > 0
448+
? pendingScopes.filter((scope) => !approvedBaseline.includes(scope))
449+
: pendingScopes;
450+
if (requestedScopeDelta.length === 0 && params.existingToken) {
451+
return resolveRoleScopedDeviceTokenScopes(params.role, params.existingToken.scopes);
452+
}
453+
return resolveRoleScopedDeviceTokenScopes(
454+
params.role,
455+
mergeScopes(params.existingToken?.scopes, requestedScopeDelta),
456+
);
443457
}
444458
return resolveRoleScopedDeviceTokenScopes(
445459
params.role,

0 commit comments

Comments
 (0)