Skip to content

Commit c9053ff

Browse files
authored
fix(pairing): preserve narrowed token scopes on upgrade (#79206)
* fix(pairing): preserve narrowed token scopes on upgrade * fix(pairing): require pending scopes for approval * fix(pairing): type approval scope merge
1 parent 07e8aec commit c9053ff

3 files changed

Lines changed: 85 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ Docs: https://docs.openclaw.ai
188188
- Gateway/media: require authenticated owner or admin context for managed outgoing image bytes instead of trusting requester-session headers.
189189
- Doctor/gateway: avoid duplicate Node runtime warnings when the daemon install plan already selected a supported Node runtime.
190190
- Gateway/nodes: ignore malformed non-string capability entries from live nodes instead of throwing while listing the node catalog.
191+
- Gateway/pairing: preserve deliberately narrowed role-token scopes when approving device scope upgrades instead of regranting the whole approved baseline.
191192
- 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.
192193
- 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.
193194
- 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
@@ -333,6 +333,66 @@ describe("device pairing tokens", () => {
333333
).resolves.toEqual({ ok: true });
334334
});
335335

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

src/infra/device-pairing.ts

Lines changed: 24 additions & 5 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,
@@ -614,16 +628,21 @@ export async function approveDevicePairing(
614628
});
615629
nextTokenScopesByRole.set(roleForToken, nextScopes);
616630
if (roleForToken === OPERATOR_ROLE && nextScopes.length > 0) {
631+
const callerRequiredScopes =
632+
mergeScopes(
633+
resolveRoleScopedDeviceTokenScopes(roleForToken, pending.scopes),
634+
nextScopes,
635+
) ?? nextScopes;
617636
if (!options?.callerScopes) {
618637
return {
619638
status: "forbidden",
620639
reason: "caller-scopes-required",
621-
scope: nextScopes[0],
640+
scope: callerRequiredScopes[0],
622641
};
623642
}
624643
const missingScope = resolveMissingRequestedScope({
625644
role: OPERATOR_ROLE,
626-
requestedScopes: nextScopes,
645+
requestedScopes: callerRequiredScopes,
627646
allowedScopes: options.callerScopes,
628647
});
629648
if (missingScope) {

0 commit comments

Comments
 (0)