v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot

POST /pair minted a new setup key but never touched the agent's live
session, so re-pairing --client X --restrict read while X was connected (or
whose 5-min key expired unexchanged) left the original full-access session,
eval included, alive up to 24h.

A reducing re-pair (fewer scopes, tighter domains, lower rate, stricter tab
policy) now revokes the live session and releases its tabs before minting
the new key (grantReducesAccess + revokeClientFully; superseded in the
response). Non-reducing re-pairs keep the session and only drop stale PENDING
setup keys, so a broaden/refresh never strands a working agent and a
narrowing re-pair issued before the agent connects can't leave the old broad
key exchangeable. Revoke happens before mint (revokeToken deletes all of a
client's tokens). CLI prints a version-skew-safe supersede notice and warns
when a re-pair-shaped call omits --client. Docs + CHANGELOG + VERSION.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-21 15:15:12 -07:00
committed by Garry Tan
co-authored by Claude Fable 5
parent 65b967c53a
commit 6fe3e67736
11 changed files with 317 additions and 7 deletions
+55
View File
@@ -318,6 +318,61 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
expect(body.error).not.toBe('Invalid request body');
});
// ─── D1: a reducing re-pair supersedes the prior grant immediately ────
const pairAs = async (body: any) => (await (await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify(body),
})).json()) as any;
const connectKey = async (setup_key: string) => {
const r = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ setup_key }),
});
return { status: r.status, body: await r.json().catch(() => ({})) as any };
};
const statusWith = (token: string) => fetch(`${daemon.baseUrl}/command`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ command: 'status', args: [] }),
});
test('reducing re-pair revokes the prior session immediately, without exchanging the new key', async () => {
const { setup_key: k1 } = await pairAs({ clientId: 'reduce-me' }); // broad
const { body: c1 } = await connectKey(k1);
const s1 = c1.token as string;
expect((await statusWith(s1)).status).not.toBe(401); // works
// Narrow WITHOUT exchanging the new key — this is the whole bug.
const rp = await pairAs({ clientId: 'reduce-me', scopes: ['read'] });
expect(rp.superseded?.tokens_deleted).toBeGreaterThanOrEqual(1);
expect((await statusWith(s1)).status).toBe(401); // old session revoked
// The new narrow key still works and yields the reduced scope.
const c2 = await connectKey(rp.setup_key);
expect(c2.status).toBe(200);
expect(c2.body.scopes).toEqual(['read']);
expect((await statusWith(c2.body.token)).status).not.toBe(401);
});
test('reducing re-pair BEFORE connect kills the stale broad setup key; only the narrow key works', async () => {
const { setup_key: broad } = await pairAs({ clientId: 'shadow' }); // never connected
const rp = await pairAs({ clientId: 'shadow', scopes: ['read'] }); // narrowing re-pair
expect(rp.superseded).toBeUndefined(); // no live session existed
expect((await connectKey(broad)).status).toBe(401); // stale broad key dead
const c = await connectKey(rp.setup_key);
expect(c.status).toBe(200);
expect(c.body.scopes).toEqual(['read']); // narrow key survives
});
test('broadening re-pair does NOT revoke the working session (no outage)', async () => {
const first = await pairAs({ clientId: 'broaden', scopes: ['read'] });
expect(first.superseded).toBeUndefined(); // first pair supersedes nothing
const { body: c } = await connectKey(first.setup_key);
const s = c.token as string;
expect((await statusWith(s)).status).not.toBe(401);
const rp = await pairAs({ clientId: 'broaden', scopes: ['read', 'write'] }); // broaden
expect(rp.superseded).toBeUndefined(); // session not superseded
expect((await statusWith(s)).status).not.toBe(401); // still working
});
// ─── D3: DELETE /token releases tabs unconditionally; 404 only when empty ─
test('DELETE /token returns tabs_released and 404 only when nothing to revoke or release', async () => {