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

* fix(pairing): reject reserved clientId 'root' at all token writers

'root' is the sentinel checkScope/checkDomain/checkRate and the server
command gate use for the omnipotent caller, so a scoped token carrying it
bypasses every enforcement path. Add ReservedClientIdError + a shared
assertValidClientId; createToken/createSetupKey throw, restoreRegistry
skips-and-logs (a corrupt state file must not brick boot). /pair and /token
surface it as a named 400, and the CLI fast-fails --client root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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>

* 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>

* fix(pairing): harden re-pair per adversarial review

Adversarial review of the diff found four issues, now fixed:
- Validate the requested grant BEFORE the supersede revoke: a reducing
  re-pair with a bad scope/rate no longer destroys the live session and
  then fails to mint a replacement (assertValidTokenOptions runs up front).
- A re-pair with no live session releases tabs orphaned by an expired
  incarnation, closing the tab-inheritance gap /pair had (DELETE /token
  already released unconditionally).
- Test the DELETE /token revoked=0/tabs>0 path and the /pair orphaned-tab
  release at the handler level (HTTP e2e can't, headless owns no tabs).
- Test the CLI --client root fast-fail; fix its null-guard (parseFlag
  returns null when --client is absent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garry@ycombinator.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-21 15:32:26 -07:00
committed by GitHub
co-authored by Claude Fable 5 Garry Tan
parent 51932eceef
commit 85fd9db554
15 changed files with 592 additions and 18 deletions
+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