fix(pairing): release tab ownership on revoke

tabOwnership cleared only on tab close, so after DELETE /token a same-name
re-pair inherited the revoked agent's authenticated tabs (own-only access
keys on owner === clientId). Add BrowserManager.releaseClientTabs and run it
unconditionally in DELETE /token (ownership outlives the token, so an
expired-token client can still own tabs); 404 only when both nothing was
revoked and nothing released. Response now carries tabs_released.

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 783504d7ea
commit 65b967c53a
4 changed files with 85 additions and 3 deletions
+29
View File
@@ -318,6 +318,35 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
expect(body.error).not.toBe('Invalid request body');
});
// ─── 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 () => {
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'd3-agent' }),
});
const { setup_key } = await pairResp.json() as any;
await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key }),
});
const del = await fetch(`${daemon.baseUrl}/token/d3-agent`, {
method: 'DELETE', headers: { Authorization: `Bearer ${daemon.token}` },
});
expect(del.status).toBe(200);
const body = await del.json() as any;
expect(body.tokens_deleted).toBeGreaterThanOrEqual(1);
// Headless-skip daemon owns no real tabs, but the field is always present.
expect(body.tabs_released).toBe(0);
// Nothing to revoke AND nothing to release → 404.
const del2 = await fetch(`${daemon.baseUrl}/token/nonexistent-xyz`, {
method: 'DELETE', headers: { Authorization: `Bearer ${daemon.token}` },
});
expect(del2.status).toBe(404);
});
// ─── Revocation e2e: revoke-all + the /agents verification surface ────
test('DELETE /token revokes session AND setup keys; agent leaves /agents; token 401s; re-connect fails', async () => {
+29
View File
@@ -99,6 +99,35 @@ describe('Tab Isolation', () => {
expect(() => bm.transferTab(999, 'agent-1')).toThrow('Tab 999 not found');
});
});
// D3: revocation must release tab ownership, or a same-name re-pair inherits
// the revoked agent's authenticated tabs (own-only access keys on
// owner === clientId). Prime the ownership map directly — newTab needs a
// real browser (see the file header note on private-map injection).
describe('releaseClientTabs (D3)', () => {
it('deletes only the target client\'s ownership and returns the released ids', () => {
const own = (bm as any).tabOwnership as Map<number, string>;
own.set(1, 'codex'); own.set(2, 'codex'); own.set(3, 'other');
const released = bm.releaseClientTabs('codex').sort((a, b) => a - b);
expect(released).toEqual([1, 2]);
expect(bm.getTabOwner(1)).toBeNull();
expect(bm.getTabOwner(2)).toBeNull();
expect(bm.getTabOwner(3)).toBe('other');
});
it('after release, own-only access to the freed tab is denied even for the same name', () => {
const own = (bm as any).tabOwnership as Map<number, string>;
own.set(1, 'codex');
expect(bm.checkTabAccess(1, 'codex', { ownOnly: true })).toBe(true); // owns it
bm.releaseClientTabs('codex');
// Ownership gone → a re-paired 'codex' can no longer read/write tab 1.
expect(bm.checkTabAccess(1, 'codex', { ownOnly: true })).toBe(false);
});
it('is a no-op on a client that owns nothing', () => {
expect(bm.releaseClientTabs('nobody')).toEqual([]);
});
});
});
// Test the instruction block generator