mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-17 02:15:31 +02:00
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:
co-authored by
Claude Fable 5
parent
65b967c53a
commit
6fe3e67736
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user