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:
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [1.68.3.0] - 2026-08-20
|
||||
|
||||
**Re-pairing a browser agent to narrow its access now revokes the old access on**
|
||||
**the spot, revoke frees the agent's tabs, and `root` is a reserved client name.**
|
||||
|
||||
Tightening a paired agent is supposed to be one re-pair away. It wasn't. `POST /pair` minted a fresh setup key but never touched the agent's live session, so `pair-agent --client codex --restrict read` against an agent that had already connected (or whose new 5-minute key simply expired unexchanged) left the original full-access session, `eval` included, alive for up to 24 hours. Revocation also never released tab ownership, so an agent re-paired under the same name inherited the previous one's authenticated tabs. And because `root` is the sentinel the scope, domain, rate, and tab checks use for the omnipotent caller, `--client root` minted a "scoped" token that skipped all of them.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Source: the before/after `BROWSE_HEADLESS_SKIP=1` daemon transcript in the PR and the regression tests in `browse/test/pair-agent-e2e.test.ts`, `browse/test/token-registry.test.ts`, and `browse/test/tab-isolation.test.ts`, which fail on the previous release.
|
||||
|
||||
| Property | Before | After |
|
||||
|--------|--------|-------|
|
||||
| Re-pair to narrow, agent hasn't reconnected | old wide session lives ~24h | old session 401s immediately |
|
||||
| Narrowing re-pair before the agent connects | stale broad setup key still exchangeable | broad key dead, only the narrow key works |
|
||||
| Broaden/refresh re-pair mid-task | old session lingers alongside a new key | working session kept, stale key dropped, no outage |
|
||||
| Revoke a paired agent | tabs stay owned; same-name re-pair inherits them | tab ownership released; own-only access denied |
|
||||
| `--client root` | "scoped" token bypasses all enforcement | rejected with a named 400 and a CLI fast-fail |
|
||||
|
||||
### What this means for you
|
||||
|
||||
Re-pair is now the real tightening lever. Re-pair an agent with its **same `--client` name** and a narrower `--restrict`/`--domain`, and the previous session is revoked and its tabs released the instant you run it, so the old access can't linger while you wait for the agent to reconnect. Broadening or refreshing the same agent leaves its working session alone, so you never strand an agent mid-task. Revoking (or a narrowing re-pair) also frees the tabs the agent opened, so reusing a client name can't hand the next agent someone else's logged-in page. `root` is rejected as a client name on both the CLI and the daemon.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
- A reducing re-pair (`/pair` with fewer scopes, tighter domains, a lower rate, or a stricter tab policy) revokes the client's live session and releases its tabs before minting the new key; the response carries `superseded`. Non-reducing re-pairs keep the session and only drop stale pending setup keys, so a broaden or refresh never strands a working agent. The requested grant is validated before any revoke, so a re-pair with a bad scope or rate is rejected without knocking the live session offline. (`browse/src/server.ts`, `browse/src/token-registry.ts`)
|
||||
- A narrowing re-pair issued before the agent connects invalidates the earlier, broader setup key, so it can no longer be exchanged. (`browse/src/token-registry.ts`)
|
||||
- Revoking an agent releases the tab ownership it held: `DELETE /token` runs the release unconditionally (ownership outlives the token) and reports `tabs_released`, and an own-only client re-paired under the same name can no longer read those tabs. A re-pair with no live session likewise frees any tabs orphaned by an expired incarnation, so a fresh session can't inherit them. (`browse/src/browser-manager.ts`, `browse/src/server.ts`)
|
||||
- `root` is rejected as a `clientId` at every token writer, so a scoped token can never carry the sentinel that bypasses scope, domain, rate, and tab checks; `/pair` and `/token` return a named 400 and the CLI rejects `--client root` before it reaches the daemon. A persisted `root` entry is skipped when the registry is restored. (`browse/src/token-registry.ts`, `browse/src/cli.ts`)
|
||||
|
||||
#### For contributors
|
||||
- Regression coverage pins each property: the reduce / broaden / shadow-key re-pair behaviors and the `grantReducesAccess` truth table (scope, domain direction, rate `0`=unlimited, tab policy) in `browse/test/pair-agent-e2e.test.ts` and `browse/test/token-registry.test.ts`; tab-ownership release and post-release denial in `browse/test/tab-isolation.test.ts`; reserved-name rejection across writers, routes, and registry restore.
|
||||
|
||||
## [1.68.2.0] - 2026-08-20
|
||||
|
||||
**Revoking a paired agent now revokes everything it holds, and the**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) ──────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -150,6 +150,8 @@ CSS selectors. Always `snapshot -i` first, then use the refs.
|
||||
|
||||
Paired agents get `read+write+admin+meta` by default; the pairing ceremony is the trust boundary. `--restrict` narrows the list (it can never grant `control`). `--control` adds the control scope (`--admin` is a legacy alias). Over the tunnel, the `js`/`cookies`/`storage` commands are blocked by the command allowlist regardless of scope; `eval` works. Pair with `--restrict "read,write"` when the agent will read untrusted web content — scope caps the prompt-injection blast radius.
|
||||
|
||||
To tighten an already-paired agent, re-pair it with the **same `--client` name** and the narrower `--restrict`/`--domain`: a reducing re-pair revokes the previous session and releases its tabs immediately (the agent must reconnect with the new key), so the old wide access never lingers. Broadening or refreshing keeps the working session with no outage. Re-pairing without `--client` mints a new agent instead. `root` is a reserved client name.
|
||||
|
||||
## Tab Isolation
|
||||
|
||||
Each agent owns the tabs it creates. Rules:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.68.2",
|
||||
"version": "1.68.3",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+10
-2
@@ -1102,6 +1102,13 @@ With --restrict (`--restrict read`, `--restrict "read,write"`):
|
||||
a trusted agent can be prompt-injected by pages it reads, and scope caps the
|
||||
blast radius (eval works over the tunnel).
|
||||
- `--restrict` never grants `control`; that scope stays behind --control.
|
||||
- To tighten an agent that is ALREADY paired, re-pair it with the **same
|
||||
`--client` name** and the narrower `--restrict`/`--domain`. A reducing re-pair
|
||||
revokes the previous session immediately and releases its tabs — the agent
|
||||
must reconnect with the new key, so the old wide access does not linger.
|
||||
Re-pairing without `--client` mints a brand-new agent and leaves the old one
|
||||
untouched. Broadening or refreshing keeps the working session (no outage).
|
||||
- `root` is a reserved `--client` name (it would bypass all scope enforcement).
|
||||
|
||||
With --control (--admin is the legacy alias):
|
||||
- Everything, plus browser-wide destructive ops (stop, restart, disconnect)
|
||||
@@ -1112,8 +1119,9 @@ With --control (--admin is the legacy alias):
|
||||
**"Tab not owned by your agent"** — The remote agent tried to interact with a tab
|
||||
it didn't create. Tell it to run `newtab` first to get its own tab.
|
||||
|
||||
**"Domain not allowed"** — The token has domain restrictions. Re-pair with broader
|
||||
domain access or no domain restrictions.
|
||||
**"Domain not allowed"** — The token has domain restrictions. Re-pair with the
|
||||
same `--client` name and broader (or no) `--domain`. A broadening re-pair keeps
|
||||
the working session; a narrowing one revokes it immediately.
|
||||
|
||||
**"Rate limit exceeded"** — The agent is sending > 10 requests/second. It should
|
||||
wait for the Retry-After header and slow down.
|
||||
|
||||
@@ -287,6 +287,13 @@ With --restrict (`--restrict read`, `--restrict "read,write"`):
|
||||
a trusted agent can be prompt-injected by pages it reads, and scope caps the
|
||||
blast radius (eval works over the tunnel).
|
||||
- `--restrict` never grants `control`; that scope stays behind --control.
|
||||
- To tighten an agent that is ALREADY paired, re-pair it with the **same
|
||||
`--client` name** and the narrower `--restrict`/`--domain`. A reducing re-pair
|
||||
revokes the previous session immediately and releases its tabs — the agent
|
||||
must reconnect with the new key, so the old wide access does not linger.
|
||||
Re-pairing without `--client` mints a brand-new agent and leaves the old one
|
||||
untouched. Broadening or refreshing keeps the working session (no outage).
|
||||
- `root` is a reserved `--client` name (it would bypass all scope enforcement).
|
||||
|
||||
With --control (--admin is the legacy alias):
|
||||
- Everything, plus browser-wide destructive ops (stop, restart, disconnect)
|
||||
@@ -297,8 +304,9 @@ With --control (--admin is the legacy alias):
|
||||
**"Tab not owned by your agent"** — The remote agent tried to interact with a tab
|
||||
it didn't create. Tell it to run `newtab` first to get its own tab.
|
||||
|
||||
**"Domain not allowed"** — The token has domain restrictions. Re-pair with broader
|
||||
domain access or no domain restrictions.
|
||||
**"Domain not allowed"** — The token has domain restrictions. Re-pair with the
|
||||
same `--client` name and broader (or no) `--domain`. A broadening re-pair keeps
|
||||
the working session; a narrowing one revokes it immediately.
|
||||
|
||||
**"Rate limit exceeded"** — The agent is sending > 10 requests/second. It should
|
||||
wait for the Retry-After header and slow down.
|
||||
|
||||
Reference in New Issue
Block a user