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
+19
View File
@@ -1056,6 +1056,25 @@ export class BrowserManager {
this.tabOwnership.set(tabId, toClientId);
}
/**
* Release all tab ownership held by a client (called on revoke / reducing
* re-pair). Deletes the ownership entries so an own-only client re-pairing
* under the same name can no longer inherit the revoked agent's
* authenticated tabs (checkTabAccess gates own-only on owner === clientId).
* Tabs are NOT closed — leaving the page open is the local human's call, not
* the daemon's; the residual is a root-only tab. Returns the released ids.
*/
releaseClientTabs(clientId: string): number[] {
const released: number[] = [];
for (const [tabId, owner] of this.tabOwnership) {
if (owner === clientId) {
this.tabOwnership.delete(tabId);
released.push(tabId);
}
}
return released;
}
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
for (const [id, page] of this.pages) {
+21
View File
@@ -1225,6 +1225,14 @@ async function tunnelAgents(): Promise<number> {
* opposite of the user's intent. And `control` never rides in via --restrict:
* browser-wide destructive ops stay behind the explicit --control flag. */
function validatePairAgentFlags(args: string[]): void {
// `root` is the sentinel that bypasses all scope/domain/rate/tab enforcement;
// naming an agent that way would silently un-sandbox it. Reject client-side
// before hitting the daemon (the server rejects it too).
const client = parseFlag(args, '--client');
if (client && client.trim().toLowerCase() === 'root') {
console.error("[browse] --client 'root' is reserved — it would bypass all scope enforcement. Choose another name.");
process.exit(1);
}
// hasFlag/parseFlag are exact-token matches, so `--restrict=read` would
// sail past every check below and silently grant FULL access.
if (args.some(a => a.startsWith('--restrict='))) {
@@ -1305,8 +1313,21 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
scopes: string[];
tunnel_url: string | null;
server_url: string;
superseded?: { tokens_deleted: number; tabs_released: number };
};
// Version-skew safe: only speak when the daemon actually superseded a live
// session (old daemons omit the field, so a new CLI never claims a false one).
if (pairData.superseded && pairData.superseded.tokens_deleted > 0) {
console.log(`[browse] Superseded the previous session for "${clientName}" (${pairData.superseded.tokens_deleted} token(s), ${pairData.superseded.tabs_released} tab(s) released). The agent must reconnect with the new key.`);
}
// A re-pair narrows/changes an EXISTING agent only when it reuses that agent's
// --client name. Without one, this mints a brand-new agent and the old grant
// lives on — warn when the intent looks like a re-pair.
if (!parseFlag(args, '--client') && (restrict || domains)) {
console.warn(`[browse] No --client given: this pairs a NEW agent and does NOT narrow an existing one. To change an agent's access, re-pair with its --client name (see 'browse tunnel agents').`);
}
// Determine the URL to use
let serverUrl: string;
if (pairData.tunnel_url) {
+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' },
});
+139 -1
View File
@@ -106,7 +106,7 @@ export const DEFAULT_PAIR_SCOPES: readonly ScopeCategory[] = ['read', 'write', '
*/
export class InvalidScopeError extends Error {}
function assertValidTokenOptions(scopes: readonly string[], rateLimit: number): void {
export function assertValidTokenOptions(scopes: readonly string[], rateLimit: number): void {
const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta', 'control'];
for (const s of scopes) {
if (!validScopes.includes(s as ScopeCategory)) {
@@ -116,6 +116,26 @@ function assertValidTokenOptions(scopes: readonly string[], rateLimit: number):
if (rateLimit < 0) throw new InvalidScopeError('rateLimit must be >= 0');
}
/**
* Typed error for a reserved or malformed clientId. `root` is the sentinel that
* checkScope/checkDomain/checkRate and the server command gate use to mean "the
* omnipotent root caller" (validateToken:360), so a scoped token carrying it
* would bypass every enforcement path. Empty/non-string ids collapse distinct
* agents together and break revoke-by-clientId. Request-path writers throw;
* restoreRegistry skips-and-logs so one bad state-file entry can't drop later
* sessions or brick boot.
*/
export class ReservedClientIdError extends Error {}
export function assertValidClientId(clientId: unknown): asserts clientId is string {
if (typeof clientId !== 'string' || clientId.trim() === '') {
throw new ReservedClientIdError('clientId must be a non-empty string');
}
if (clientId.trim().toLowerCase() === 'root') {
throw new ReservedClientIdError("clientId 'root' is reserved");
}
}
// ─── Types ──────────────────────────────────────────────────────
export interface TokenInfo {
@@ -234,6 +254,7 @@ export function createToken(opts: CreateTokenOptions): TokenInfo {
} = opts;
// Validate inputs
assertValidClientId(clientId);
assertValidTokenOptions(scopes, rateLimit);
if (expiresSeconds !== null && expiresSeconds !== undefined && expiresSeconds < 0) {
throw new Error('expiresSeconds must be >= 0 or null');
@@ -276,6 +297,9 @@ export function createToken(opts: CreateTokenOptions): TokenInfo {
* Setup keys expire in 5 minutes and can only be exchanged once.
*/
export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { clientId?: string }): TokenInfo {
// Only validate when a clientId is supplied; an omitted one gets a safe
// generated `remote-<ts>` default below.
if (opts.clientId !== undefined) assertValidClientId(opts.clientId);
const scopes = opts.scopes || ['read', 'write'];
// ?? not ||: rateLimit 0 is documented as "unlimited" and must survive.
const rateLimit = opts.rateLimit ?? 10;
@@ -471,6 +495,110 @@ export function revokeToken(clientId: string): number {
return deleted;
}
/**
* Revoke the PENDING (unspent) setup keys for a client, leaving any live
* session AND spent keys untouched. A re-pair always drops pending keys so a
* superseded broad key can never be exchanged this closes the shadow-key
* hole (a reducing re-pair before the agent connects would otherwise leave the
* old broad key live) without touching the spent key that #2646 keeps for
* idempotent re-exchange on a tunnel drop. Returns the number deleted.
*/
export function revokeSetupKeys(clientId: string): number {
let deleted = 0;
for (const [token, info] of tokens) {
// usesRemaining !== 0 = still exchangeable (pending). Spent keys (0) are
// harmless: their session is either kept here or revoked on the reduce path.
if (info.clientId === clientId && info.type === 'setup' && info.usesRemaining !== 0) {
tokens.delete(token);
deleted++;
}
}
return deleted;
}
/** The live (non-expired) session token for a client, if any. */
export function getClientSession(clientId: string): TokenInfo | null {
const now = new Date();
for (const info of tokens.values()) {
if (info.clientId !== clientId || info.type !== 'session') continue;
if (info.expiresAt && new Date(info.expiresAt) < now) continue;
return info;
}
return null;
}
/** The effective grant a re-pair is requesting, resolved to concrete values. */
export interface ResolvedGrant {
scopes: ScopeCategory[];
domains?: string[];
rateLimit: number;
tabPolicy: 'own-only' | 'shared';
}
/**
* Does `grant` remove any capability the live `prior` session holds? Drives the
* /pair supersede decision: a reducing re-pair revokes the old session
* immediately (the narrowing must not wait for a reconnect that may never
* happen); a broaden/refresh leaves it working (no outage). Fails toward
* revocation on an unprovable domain superset a spurious revoke costs one
* reconnect, a missed one leaves wide access live.
*/
export function grantReducesAccess(prior: TokenInfo, grant: ResolvedGrant): boolean {
return scopesReduced(prior.scopes, grant.scopes)
|| domainsReduced(prior.domains, grant.domains)
|| rateReduced(prior.rateLimit, grant.rateLimit)
|| tabPolicyReduced(prior.tabPolicy, grant.tabPolicy);
}
function scopesReduced(prior: ScopeCategory[], next: ScopeCategory[]): boolean {
// Any scope the prior held that the new grant omits (also catches dropping 'control').
return prior.some(s => !next.includes(s));
}
function domainsReduced(prior: string[] | undefined, next: string[] | undefined): boolean {
const priorUnrestricted = !prior || prior.length === 0;
const nextUnrestricted = !next || next.length === 0;
if (priorUnrestricted) return !nextUnrestricted; // universe → restricted = reduce
if (nextUnrestricted) return false; // restricted → universe = broaden
// Both restricted: reduced if any host the prior allowlist admits is no longer
// admitted by the new one. Approximate over patterns — prior is covered iff
// every prior pattern is covered by some next pattern; anything unprovable
// counts as reduced (fail toward revocation).
return prior!.some(p => !next!.some(n => domainGlobCovers(n, p)));
}
/** Does allowlist pattern `wide` admit every host that `narrow` admits? Mirrors
* matchDomainGlob's suffix/exact rules. */
function domainGlobCovers(wide: string, narrow: string): boolean {
if (wide === narrow) return true;
const wideGlob = wide.startsWith('*.');
if (wideGlob) {
const wideSuffix = wide.slice(1); // ".example.com"
const wideApex = wide.slice(2); // "example.com"
if (!narrow.startsWith('*.')) {
// narrow is an exact host; covered iff the wide glob matches it.
return narrow === wideApex || narrow.endsWith(wideSuffix);
}
// narrow is also a glob; its apex must fall under the wide suffix.
const narrowApex = narrow.slice(2);
return narrowApex === wideApex || narrowApex.endsWith(wideSuffix);
}
// wide is an exact host: covers only the identical host (handled by === above).
return false;
}
function rateReduced(prior: number, next: number): boolean {
const priorUnlimited = prior <= 0; // 0 = unlimited
const nextUnlimited = next <= 0;
if (priorUnlimited) return !nextUnlimited; // unlimited → capped = reduce
if (nextUnlimited) return false; // capped → unlimited = broaden
return next < prior; // both capped: a lower cap = reduce
}
function tabPolicyReduced(prior: 'own-only' | 'shared', next: 'own-only' | 'shared'): boolean {
return prior === 'shared' && next === 'own-only';
}
/**
* Rotate the root token. All scoped tokens are invalidated.
* Returns the new root token.
@@ -535,6 +663,16 @@ export function restoreRegistry(state: TokenRegistryState): void {
// Skip expired tokens
if (data.expiresAt && new Date(data.expiresAt) < now) continue;
// Skip-and-log rather than throw: a hand-edited or corrupt state file must
// not brick boot or drop every later valid session. A persisted clientId
// 'root' would otherwise inject a token that bypasses all scope checks.
try {
assertValidClientId(clientId);
} catch (err) {
console.warn(`[browse] restoreRegistry: skipping invalid clientId ${JSON.stringify(clientId)}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
tokens.set(data.token, {
...data,
clientId,
+129
View File
@@ -291,6 +291,135 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
expect(body.hint).not.toContain('--admin');
});
// ─── D2: reserved clientId is rejected with a named 400 ───────────────
test('POST /pair with clientId "root" returns 400 naming the reservation', async () => {
const resp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'root' }),
});
expect(resp.status).toBe(400);
const body = await resp.json() as any;
// The reservation is named, NOT hidden behind the generic "Invalid request body".
expect(body.error).toContain('root');
expect(body.error).not.toBe('Invalid request body');
});
test('POST /token with clientId "root" returns 400 naming the reservation', async () => {
const resp = await fetch(`${daemon.baseUrl}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'root' }),
});
expect(resp.status).toBe(400);
const body = await resp.json() as any;
expect(body.error).toContain('root');
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
});
test('a reducing re-pair with an INVALID scope 400s and leaves the live session intact', async () => {
// Regression: the supersede revoke must run AFTER validation. A scope typo
// (--restrict red) on a narrowing re-pair must not destroy the session and
// then fail to mint a replacement — the agent would be knocked offline.
const { setup_key: k } = await pairAs({ clientId: 'validate-me' });
const { body: c } = await connectKey(k);
const s = c.token as string;
expect((await statusWith(s)).status).not.toBe(401);
const resp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'validate-me', scopes: ['red'] }),
});
expect(resp.status).toBe(400);
expect((await resp.json() as any).error).toContain('red');
// The working session survives the validation error (not revoked).
expect((await statusWith(s)).status).not.toBe(401);
});
// ─── 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 () => {
+35
View File
@@ -370,6 +370,41 @@ describe('buildFetchHandler factory contract', () => {
initRegistry('first-token-pad-to-16-chars');
expect(() => initRegistry('second-token-pad-to-16-chars')).toThrow(/already initialized/i);
});
// D3 handler gating: tab ownership outlives token expiry, so DELETE /token
// must release tabs and 200 even when no token remains (revoked=0, tabs>0).
// Drives the handler into that exact state — HTTP e2e can't (headless-skip
// owns no real tabs), so the revoke-gated-release regression would pass there.
test('13. DELETE /token releases orphaned tabs and 200s when no token remains', async () => {
const cfg = makeMinimalConfig();
(cfg.browserManager as any).tabOwnership.set(7, 'ghost'); // owned, no token
const handle = buildFetchHandler(cfg);
const req = new Request('http://127.0.0.1/token/ghost', {
method: 'DELETE', headers: { Authorization: `Bearer ${cfg.authToken}` },
});
const resp = await handle.fetchLocal(req, null);
expect(resp.status).toBe(200);
const body = await resp.json() as { tokens_deleted: number; tabs_released: number };
expect(body.tokens_deleted).toBe(0);
expect(body.tabs_released).toBe(1);
expect(cfg.browserManager.getTabOwner(7)).toBeNull();
});
// D1 F2: a re-pair with no LIVE session must release tabs orphaned by an
// expired incarnation, or the new session inherits its authenticated pages.
test('14. /pair releases tabs orphaned by an expired session (no inheritance)', async () => {
const cfg = makeMinimalConfig();
(cfg.browserManager as any).tabOwnership.set(9, 'ghost'); // orphaned, no live session
const handle = buildFetchHandler(cfg);
const req = new Request('http://127.0.0.1/pair', {
method: 'POST',
headers: { Authorization: `Bearer ${cfg.authToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ clientId: 'ghost', scopes: ['read'] }),
});
const resp = await handle.fetchLocal(req, null);
expect(resp.status).toBe(200);
expect(cfg.browserManager.getTabOwner(9)).toBeNull();
});
});
// ─── Idle timer + onDisconnect dual-instance fix (v1.42.3.0) ──────────
+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
+89 -1
View File
@@ -6,7 +6,9 @@ import {
revokeToken, rotateRoot, listTokens, recordCommand,
serializeRegistry, restoreRegistry, checkConnectRateLimit,
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META,
DEFAULT_PAIR_SCOPES, InvalidScopeError,
DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError,
revokeSetupKeys, getClientSession, grantReducesAccess,
type TokenInfo, type ResolvedGrant,
__resetRegistry,
} from '../src/token-registry';
@@ -19,6 +21,92 @@ describe('token-registry', () => {
initRegistry('root-token-for-tests');
});
// D2: `root` is the sentinel checkScope/checkDomain/checkRate use for the
// omnipotent caller; a scoped token carrying it bypasses all enforcement.
describe('reserved clientId (D2)', () => {
it('createToken rejects clientId "root" and lookalikes', () => {
expect(() => createToken({ clientId: 'root' })).toThrow(ReservedClientIdError);
expect(() => createToken({ clientId: 'ROOT' })).toThrow(ReservedClientIdError);
expect(() => createToken({ clientId: ' root ' })).toThrow(ReservedClientIdError);
});
it('createToken rejects empty / whitespace clientId', () => {
expect(() => createToken({ clientId: '' })).toThrow(ReservedClientIdError);
expect(() => createToken({ clientId: ' ' })).toThrow(ReservedClientIdError);
});
it('createSetupKey rejects clientId "root" but allows an omitted one', () => {
expect(() => createSetupKey({ clientId: 'root' })).toThrow(ReservedClientIdError);
// Omitted clientId gets a safe generated default, not a throw.
const key = createSetupKey({});
expect(key.clientId.startsWith('remote-')).toBe(true);
});
it('revokeSetupKeys drops only PENDING keys, keeping the spent key and the session', () => {
const k1 = createSetupKey({ clientId: 'x' }); // pending
exchangeSetupKey(k1.token); // k1 now spent + a session exists
createSetupKey({ clientId: 'x' }); // pending k2
expect(revokeSetupKeys('x')).toBe(1); // only the pending k2
expect(getClientSession('x')).not.toBeNull(); // session kept
// The spent key survives for idempotent re-exchange (#2646).
expect(exchangeSetupKey(k1.token)).not.toBeNull();
});
it('restoreRegistry skips a persisted "root" entry instead of injecting a bypass token', () => {
restoreRegistry({ agents: {
root: { token: 'gsk_sess_evil', type: 'session', scopes: ['read', 'write', 'admin', 'meta', 'control'], tabPolicy: 'shared', rateLimit: 0, expiresAt: null, createdAt: new Date().toISOString() } as any,
good: { token: 'gsk_sess_good', type: 'session', scopes: ['read'], tabPolicy: 'own-only', rateLimit: 10, expiresAt: null, createdAt: new Date().toISOString() } as any,
} });
// The evil root entry is dropped; the valid one still restores.
expect(validateToken('gsk_sess_evil')).toBeNull();
const good = validateToken('gsk_sess_good');
expect(good?.clientId).toBe('good');
});
});
// D1: drives the /pair supersede decision. Direction matters — dropping an
// allowlisted domain is the reduction, not adding one; 0 = unlimited rate.
describe('grantReducesAccess (D1)', () => {
const prior = (o: Partial<TokenInfo> = {}): TokenInfo => ({
token: 't', clientId: 'c', type: 'session',
scopes: ['read', 'write', 'admin', 'meta'], tabPolicy: 'own-only',
rateLimit: 10, expiresAt: null, createdAt: '', commandCount: 0, ...o,
});
const grant = (o: Partial<ResolvedGrant> = {}): ResolvedGrant => ({
scopes: ['read', 'write', 'admin', 'meta'], rateLimit: 10, tabPolicy: 'own-only', ...o,
});
it('scopes: drop → reduce; add/equal → not; dropping control → reduce', () => {
expect(grantReducesAccess(prior({ scopes: ['read', 'write', 'admin', 'meta'] }), grant({ scopes: ['read'] }))).toBe(true);
expect(grantReducesAccess(prior({ scopes: ['read'] }), grant({ scopes: ['read', 'write'] }))).toBe(false);
expect(grantReducesAccess(prior({ scopes: ['read'] }), grant({ scopes: ['read'] }))).toBe(false);
expect(grantReducesAccess(prior({ scopes: ['read', 'control'] }), grant({ scopes: ['read'] }))).toBe(true);
});
it('domains: drop → reduce; add/equal → not; unrestricted→restricted → reduce; glob narrowing → reduce', () => {
expect(grantReducesAccess(prior({ domains: ['a.com', 'b.com'] }), grant({ domains: ['a.com'] }))).toBe(true);
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: ['a.com', 'b.com'] }))).toBe(false);
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: ['a.com'] }))).toBe(false);
expect(grantReducesAccess(prior({ domains: undefined }), grant({ domains: ['a.com'] }))).toBe(true);
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: undefined }))).toBe(false);
expect(grantReducesAccess(prior({ domains: ['*.example.com'] }), grant({ domains: ['*.com'] }))).toBe(false); // widen
expect(grantReducesAccess(prior({ domains: ['*.com'] }), grant({ domains: ['*.example.com'] }))).toBe(true); // narrow
});
it('rate (0 = unlimited): unlimited→capped → reduce; lower cap → reduce; higher/equal → not', () => {
expect(grantReducesAccess(prior({ rateLimit: 0 }), grant({ rateLimit: 10 }))).toBe(true);
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 5 }))).toBe(true);
expect(grantReducesAccess(prior({ rateLimit: 5 }), grant({ rateLimit: 10 }))).toBe(false);
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 10 }))).toBe(false);
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 0 }))).toBe(false); // → unlimited = broaden
});
it('tabPolicy: shared → own-only → reduce; the reverse → not', () => {
expect(grantReducesAccess(prior({ tabPolicy: 'shared' }), grant({ tabPolicy: 'own-only' }))).toBe(true);
expect(grantReducesAccess(prior({ tabPolicy: 'own-only' }), grant({ tabPolicy: 'shared' }))).toBe(false);
});
});
describe('root token', () => {
it('identifies root token correctly', () => {
expect(isRootToken('root-token-for-tests')).toBe(true);
+15
View File
@@ -155,6 +155,21 @@ describe('pair-agent scope-flag validation (pre-server)', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('--client root → exit 1 reserved-name error, NO daemon spawned', async () => {
// `root` is the sentinel that bypasses every scope/domain/rate/tab check;
// the CLI must reject it pre-server (the daemon also 400s it as defense-in-depth).
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-client-root-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--client', 'root'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('reserved');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});
describe('tunnel against no daemon (#2254 — never boot one)', () => {