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
+58 -10
View File
@@ -32,7 +32,8 @@ import {
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
listTokens, recordCommand,
isRootToken, checkConnectRateLimit, type TokenInfo, type ScopeCategory,
DEFAULT_PAIR_SCOPES, InvalidScopeError,
DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError, assertValidClientId,
assertValidTokenOptions, revokeSetupKeys, getClientSession, grantReducesAccess,
} from './token-registry';
import { validateTempPath } from './path-security';
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
@@ -2318,9 +2319,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
agent: session.clientId,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (err) {
// Name the caller's typo (bad scope, negative rateLimit) instead of
// hiding it behind the generic body error.
if (err instanceof InvalidScopeError) {
// Name the caller's typo (bad scope, negative rateLimit, reserved
// clientId) instead of hiding it behind the generic body error.
if (err instanceof InvalidScopeError || err instanceof ReservedClientIdError) {
return new Response(JSON.stringify({ error: err.message }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
@@ -2348,13 +2349,18 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
});
}
const revoked = revokeToken(clientId);
if (!revoked) {
// Release tabs UNCONDITIONALLY: ownership outlives the token (it clears
// only on tab close), so a client whose token already expired can still
// own tabs. Gating release on a revoke hit would orphan that ownership
// and let a same-name re-pair inherit an authenticated tab.
const tabsReleased = browserManager.releaseClientTabs(clientId).length;
if (!revoked && tabsReleased === 0) {
return new Response(JSON.stringify({ error: `Agent "${clientId}" not found` }), {
status: 404, headers: { 'Content-Type': 'application/json' },
});
}
console.log(`[browse] Revoked ${revoked} token(s) for: ${clientId}`);
return new Response(JSON.stringify({ revoked: clientId, tokens_deleted: revoked }), {
console.log(`[browse] Revoked ${revoked} token(s), released ${tabsReleased} tab(s) for: ${clientId}`);
return new Response(JSON.stringify({ revoked: clientId, tokens_deleted: revoked, tabs_released: tabsReleased }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
@@ -2392,6 +2398,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
}
try {
const pairBody = await req.json() as any;
// Reject a reserved/invalid clientId up front (createSetupKey enforces
// it too, but this makes the 400 unambiguous and skips the teardown).
if (pairBody.clientId !== undefined) assertValidClientId(pairBody.clientId);
// Default: DEFAULT_PAIR_SCOPES (full page access). The trust boundary
// is the pairing ceremony itself, not the scope. --control adds
// browser-wide destructive commands (stop, restart, disconnect).
@@ -2406,6 +2415,44 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
const scopes = pairBody.control || pairBody.admin
? [...DEFAULT_PAIR_SCOPES, 'control' as const]
: ((pairBody.scopes || [...DEFAULT_PAIR_SCOPES]) as ScopeCategory[]);
// D1: a re-pair supersedes prior grants. ALWAYS drop stale setup keys
// so a superseded broad key can never be exchanged — this closes the
// shadow-key hole where a narrowing re-pair before the agent connects
// would otherwise leave the old broad key live. Revoke the live
// SESSION only when the new grant actually reduces access, so a
// broaden/refresh never strands a working agent mid-task. Compare
// against the resolved grant (not raw pairBody) so dropping 'control'
// or a default re-pair is classified correctly. Revoke runs BEFORE
// createSetupKey — revokeToken deletes all of a clientId's tokens, so
// minting first would nuke the fresh key.
const grant = {
scopes: [...scopes] as ScopeCategory[],
domains: pairBody.domains as string[] | undefined,
rateLimit: pairBody.rateLimit ?? 10,
tabPolicy: 'own-only' as const,
};
// Validate BEFORE any revoke (createSetupKey validates too, but that
// runs after the teardown below). A bad scope or negative rateLimit
// must 400 without knocking a live session offline — otherwise a
// reducing re-pair with a typo (--restrict red) destroys the session
// and mints no replacement.
assertValidTokenOptions(grant.scopes, grant.rateLimit);
const priorSession = pairBody.clientId ? getClientSession(pairBody.clientId) : null;
let superseded: { tokens_deleted: number; tabs_released: number } | undefined;
if (priorSession && grantReducesAccess(priorSession, grant)) {
const tokensDeleted = revokeToken(pairBody.clientId);
const tabsReleased = browserManager.releaseClientTabs(pairBody.clientId).length;
superseded = { tokens_deleted: tokensDeleted, tabs_released: tabsReleased };
console.log(`[browse] Superseded ${tokensDeleted} token(s), released ${tabsReleased} tab(s) for reducing re-pair: ${pairBody.clientId}`);
} else if (pairBody.clientId) {
revokeSetupKeys(pairBody.clientId);
// No live session, but tab ownership outlives token expiry: free any
// tabs orphaned by an expired session so this re-pair can't inherit
// an earlier incarnation's authenticated pages (mirrors DELETE
// /token's unconditional release). A live-session broaden keeps its
// tabs — the working agent still owns them.
if (!priorSession) browserManager.releaseClientTabs(pairBody.clientId);
}
const setupKey = createSetupKey({
clientId: pairBody.clientId,
scopes: [...scopes],
@@ -2440,11 +2487,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
scopes: setupKey.scopes,
tunnel_url: verifiedTunnelUrl,
server_url: `http://127.0.0.1:${browsePort}`,
...(superseded ? { superseded } : {}),
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (err) {
// Name the caller's typo (bad scope, negative rateLimit) instead of
// hiding it behind the generic body error.
if (err instanceof InvalidScopeError) {
// Name the caller's typo (bad scope, negative rateLimit, reserved
// clientId) instead of hiding it behind the generic body error.
if (err instanceof InvalidScopeError || err instanceof ReservedClientIdError) {
return new Response(JSON.stringify({ error: err.message }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});