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 () => {