fix(browse): CLI always sends explicit pair scopes via shared DEFAULT_PAIR_SCOPES

The effective pairing default lived in two places: the CLI omitted scopes
unless --restrict was passed, and the server filled in its own literal.
handlePairAgent now always sends an explicit scopes list and both sides
reference one exported constant, DEFAULT_PAIR_SCOPES, so the default cannot
silently drift again (pinned by a server-auth source tripwire).

Three input traps closed in the same surface:
- Bare --restrict (or --restrict swallowing the next flag) parsed as "no
  restriction" and silently granted FULL access, the opposite of the user's
  intent. validatePairAgentFlags rejects it pre-server, before any consent
  gate, so an arg error never boots a daemon.
- A scopes list could smuggle the control scope past the explicit flag:
  --restrict "read,control" minted a control-scoped session with no
  --control. /pair now 400s on control in a scopes list without the control
  flag, and the CLI points the user at --control.
- Option typos validated only at exchange time: createSetupKey stored any
  scope string and any rateLimit, so /pair returned 200 with a poisoned
  setup key whose failure surfaced to the REMOTE agent at /connect as a
  misleading "Invalid request body". Shared validation now runs in both
  creators and throws typed InvalidScopeError; /pair and /token 400 with the
  message, naming the bad scope or negative rateLimit. Also
  `opts.rateLimit || 10` became `?? 10` so the documented "0 = unlimited"
  survives the /pair path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-20 04:00:04 +00:00
co-authored by Claude Fable 5
parent f28bfd0158
commit 44e0b6f7be
7 changed files with 280 additions and 19 deletions
+37 -2
View File
@@ -18,6 +18,9 @@ import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled } fr
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
import { redactProxyUrl } from './proxy-redact'; import { redactProxyUrl } from './proxy-redact';
import { spawnTerminalAgent } from './terminal-agent-control'; import { spawnTerminalAgent } from './terminal-agent-control';
// Zero side effects on import (documented invariant in token-registry.ts) —
// safe to pull the shared pairing default into the CLI.
import { DEFAULT_PAIR_SCOPES } from './token-registry';
const config = resolveConfig(); const config = resolveConfig();
const IS_WINDOWS = process.platform === 'win32'; const IS_WINDOWS = process.platform === 'win32';
@@ -1216,6 +1219,29 @@ async function tunnelAgents(): Promise<number> {
return 0; return 0;
} }
/** Reject pair-agent scope-flag misuse BEFORE any consent or server work.
* Bare `--restrict` (or a flag-shaped value from a forgotten argument) used
* to parse as "no restriction" and silently grant FULL access — the exact
* 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 {
if (!hasFlag(args, '--restrict')) return;
const restrict = parseFlag(args, '--restrict');
if (!restrict || !restrict.trim() || restrict.startsWith('--')) {
console.error('[browse] --restrict needs a scope list, e.g. --restrict read or --restrict "read,write". Bare --restrict would silently grant FULL access.');
process.exit(1);
}
if (hasFlag(args, '--control') || hasFlag(args, '--admin')) {
// Server-side, the control flag wins and the scopes list is ignored.
console.warn('[browse] --restrict is ignored when --control/--admin is set (control implies full access).');
return;
}
if (restrict.split(',').map(s => s.trim()).includes('control')) {
console.error('[browse] The control scope is not granted via --restrict. Re-run with --control.');
process.exit(1);
}
}
async function handleTunnel(args: string[]): Promise<never> { async function handleTunnel(args: string[]): Promise<never> {
const sub = args[0]; const sub = args[0];
if (sub === 'revoke' && args.length === 2 && args[1].trim()) { if (sub === 'revoke' && args.length === 2 && args[1].trim()) {
@@ -1236,8 +1262,12 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
const localHost = parseFlag(args, '--local'); const localHost = parseFlag(args, '--local');
// Call POST /pair to create a setup key // Call POST /pair to create a setup key
// Default: full access (read+write+admin+meta). --control adds browser-wide ops. // Default: DEFAULT_PAIR_SCOPES (full page access). --control adds browser-wide ops.
// --restrict limits: --restrict read (read-only), --restrict "read,write" (no admin) // --restrict limits: --restrict read (read-only), --restrict "read,write" (no admin)
// Scopes are ALWAYS sent explicitly so the effective default lives in one
// place (token-registry) instead of drifting between CLI omission and
// server fallback. Flag misuse was rejected pre-server by
// validatePairAgentFlags.
const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, { const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1248,7 +1278,9 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
domains, domains,
clientId: clientName, clientId: clientName,
control, control,
...(restrict ? { scopes: restrict.split(',').map(s => s.trim()) } : {}), scopes: restrict
? restrict.split(',').map(s => s.trim())
: [...DEFAULT_PAIR_SCOPES],
}), }),
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
@@ -1791,6 +1823,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
// state, so replacing it kills nothing the user had. // state, so replacing it kills nothing the user had.
let pairAgentPreexistingDaemonAlive = false; let pairAgentPreexistingDaemonAlive = false;
if (command === 'pair-agent') { if (command === 'pair-agent') {
// Scope-flag misuse is rejected before consent gates and ensureServer —
// an arg error must never boot a daemon.
validatePairAgentFlags(commandArgs);
const preState = readState(); const preState = readState();
pairAgentPreexistingDaemonAlive = Boolean(preState?.pid && isProcessAlive(preState.pid)); pairAgentPreexistingDaemonAlive = Boolean(preState?.pid && isProcessAlive(preState.pid));
} }
+31 -8
View File
@@ -31,7 +31,8 @@ import {
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain, initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken, checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
listTokens, recordCommand, listTokens, recordCommand,
isRootToken, checkConnectRateLimit, type TokenInfo, isRootToken, checkConnectRateLimit, type TokenInfo, type ScopeCategory,
DEFAULT_PAIR_SCOPES, InvalidScopeError,
} from './token-registry'; } from './token-registry';
import { validateTempPath } from './path-security'; import { validateTempPath } from './path-security';
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config'; import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
@@ -2316,7 +2317,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
scopes: session.scopes, scopes: session.scopes,
agent: session.clientId, agent: session.clientId,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }); }), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch { } catch (err) {
// Name the caller's typo (bad scope, negative rateLimit) instead of
// hiding it behind the generic body error.
if (err instanceof InvalidScopeError) {
return new Response(JSON.stringify({ error: err.message }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'Invalid request body' }), { return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400, headers: { 'Content-Type': 'application/json' }, status: 400, headers: { 'Content-Type': 'application/json' },
}); });
@@ -2384,12 +2392,20 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
} }
try { try {
const pairBody = await req.json() as any; const pairBody = await req.json() as any;
// Default: full access (read+write+admin+meta). The trust boundary is // Default: DEFAULT_PAIR_SCOPES (full page access). The trust boundary
// the pairing ceremony itself, not the scope. --control adds browser-wide // is the pairing ceremony itself, not the scope. --control adds
// destructive commands (stop, restart, disconnect). --restrict limits scope. // browser-wide destructive commands (stop, restart, disconnect).
// --restrict limits scope — but can never grant control: that scope
// stays behind the explicit control flag.
if (!pairBody.control && !pairBody.admin
&& Array.isArray(pairBody.scopes) && pairBody.scopes.includes('control')) {
return new Response(JSON.stringify({
error: 'The control scope requires the control flag (--control); it cannot be granted via a scopes list.',
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
const scopes = pairBody.control || pairBody.admin const scopes = pairBody.control || pairBody.admin
? ['read', 'write', 'admin', 'meta', 'control'] as const ? [...DEFAULT_PAIR_SCOPES, 'control' as const]
: (pairBody.scopes || ['read', 'write', 'admin', 'meta']) as const; : ((pairBody.scopes || [...DEFAULT_PAIR_SCOPES]) as ScopeCategory[]);
const setupKey = createSetupKey({ const setupKey = createSetupKey({
clientId: pairBody.clientId, clientId: pairBody.clientId,
scopes: [...scopes], scopes: [...scopes],
@@ -2425,7 +2441,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
tunnel_url: verifiedTunnelUrl, tunnel_url: verifiedTunnelUrl,
server_url: `http://127.0.0.1:${browsePort}`, server_url: `http://127.0.0.1:${browsePort}`,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }); }), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch { } catch (err) {
// Name the caller's typo (bad scope, negative rateLimit) instead of
// hiding it behind the generic body error.
if (err instanceof InvalidScopeError) {
return new Response(JSON.stringify({ error: err.message }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'Invalid request body' }), { return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400, headers: { 'Content-Type': 'application/json' }, status: 400, headers: { 'Content-Type': 'application/json' },
}); });
+39 -9
View File
@@ -82,6 +82,35 @@ const SCOPE_MAP: Record<ScopeCategory, Set<string>> = {
meta: SCOPE_META, meta: SCOPE_META,
}; };
/**
* Scopes granted by POST /pair when nothing narrower is requested.
* Deliberately full page access (b73f3644 / #907): the trust boundary is the
* pairing ceremony, not the scope. 'control' (browser-wide destructive ops)
* is the only scope that stays opt-in via the control flag. Referenced by
* BOTH server.ts (/pair default) and cli.ts (explicit send) so the two
* defaults cannot silently drift apart again.
*/
export const DEFAULT_PAIR_SCOPES: readonly ScopeCategory[] = ['read', 'write', 'admin', 'meta'];
/**
* Typed error for caller-supplied token options (unknown scope, negative
* rateLimit). HTTP handlers catch it to 400 with the message at the endpoint
* where the typo happened — pre-fix, a bad scope sailed through /pair into a
* poisoned setup key and surfaced as a misleading "Invalid request body" to
* the remote agent at /connect.
*/
export class InvalidScopeError extends Error {}
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)) {
throw new InvalidScopeError(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
}
}
if (rateLimit < 0) throw new InvalidScopeError('rateLimit must be >= 0');
}
// ─── Types ────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────
export interface TokenInfo { export interface TokenInfo {
@@ -200,13 +229,7 @@ export function createToken(opts: CreateTokenOptions): TokenInfo {
} = opts; } = opts;
// Validate inputs // Validate inputs
const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta', 'control']; assertValidTokenOptions(scopes, rateLimit);
for (const s of scopes) {
if (!validScopes.includes(s as ScopeCategory)) {
throw new Error(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
}
}
if (rateLimit < 0) throw new Error('rateLimit must be >= 0');
if (expiresSeconds !== null && expiresSeconds !== undefined && expiresSeconds < 0) { if (expiresSeconds !== null && expiresSeconds !== undefined && expiresSeconds < 0) {
throw new Error('expiresSeconds must be >= 0 or null'); throw new Error('expiresSeconds must be >= 0 or null');
} }
@@ -248,6 +271,13 @@ export function createToken(opts: CreateTokenOptions): TokenInfo {
* Setup keys expire in 5 minutes and can only be exchanged once. * Setup keys expire in 5 minutes and can only be exchanged once.
*/ */
export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { clientId?: string }): TokenInfo { export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { clientId?: string }): TokenInfo {
const scopes = opts.scopes || ['read', 'write'];
// ?? not ||: rateLimit 0 is documented as "unlimited" and must survive.
const rateLimit = opts.rateLimit ?? 10;
// Validate HERE, not only at exchange time in createToken — otherwise a
// typo mints a poisoned setup key whose failure surfaces to the wrong
// party (the remote agent, at /connect, as "Invalid request body").
assertValidTokenOptions(scopes, rateLimit);
const token = generateToken('gsk_setup_'); const token = generateToken('gsk_setup_');
const now = new Date(); const now = new Date();
const expiresAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 min const expiresAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 min
@@ -256,10 +286,10 @@ export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { cl
token, token,
clientId: opts.clientId || `remote-${Date.now()}`, clientId: opts.clientId || `remote-${Date.now()}`,
type: 'setup', type: 'setup',
scopes: opts.scopes || ['read', 'write'], scopes,
domains: opts.domains, domains: opts.domains,
tabPolicy: opts.tabPolicy || 'own-only', tabPolicy: opts.tabPolicy || 'own-only',
rateLimit: opts.rateLimit || 10, rateLimit,
expiresAt, expiresAt,
createdAt: now.toISOString(), createdAt: now.toISOString(),
usesRemaining: 1, usesRemaining: 1,
+70
View File
@@ -194,6 +194,76 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
expect(Array.isArray(scopes)).toBe(true); expect(Array.isArray(scopes)).toBe(true);
}); });
// ─── Pair scope contract: defaults, explicit lists, typo naming ───────
test('default /connect scopes are exactly read,write,admin,meta', async () => {
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'default-scopes' }),
});
const { setup_key, scopes: pairScopes } = await pairResp.json() as any;
expect(pairScopes).toEqual(['read', 'write', 'admin', 'meta']);
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key }),
});
const { scopes } = await connectResp.json() as any;
expect(scopes).toEqual(['read', 'write', 'admin', 'meta']);
});
test('explicit scopes are honored end-to-end (the --restrict wire contract)', async () => {
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'restricted-scopes', scopes: ['read'] }),
});
const { setup_key } = await pairResp.json() as any;
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key }),
});
const { scopes } = await connectResp.json() as any;
expect(scopes).toEqual(['read']);
});
test('POST /pair with a scope typo fails fast, naming the scope', async () => {
// Regression: pre-fix this returned 200 with a poisoned setup key whose
// failure surfaced at /connect as a misleading "Invalid request body".
const resp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'typo-agent', scopes: ['raed'] }),
});
expect(resp.status).toBe(400);
const body = await resp.json() as any;
expect(body.error).toContain('Invalid scope: raed');
});
test('POST /token with a scope typo names the scope too', async () => {
const resp = await fetch(`${daemon.baseUrl}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'typo-token', scopes: ['wirte'] }),
});
expect(resp.status).toBe(400);
const body = await resp.json() as any;
expect(body.error).toContain('Invalid scope: wirte');
});
test('control cannot ride in through a /pair scopes list without the control flag', async () => {
const resp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'sneaky', scopes: ['read', 'control'] }),
});
expect(resp.status).toBe(400);
const body = await resp.json() as any;
expect(body.error).toContain('control');
});
// ─── Revocation e2e: revoke-all + the /agents verification surface ──── // ─── 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 () => { test('DELETE /token revokes session AND setup keys; agent leaves /agents; token 401s; re-connect fails', async () => {
+29
View File
@@ -409,3 +409,32 @@ describe('Server auth security', () => {
expect(routeSrc).toContain('SameSite=Strict'); expect(routeSrc).toContain('SameSite=Strict');
}); });
}); });
describe('Pair scope defaults and revocation surface', () => {
// Regression: the CLI only sent scopes when --restrict was passed, so the
// effective pairing default lived in two places (CLI omission + server
// fallback) and could silently drift. Both sides must reference the shared
// DEFAULT_PAIR_SCOPES constant, and the CLI must send scopes
// unconditionally (the old conditional-spread shape is banned).
test('/pair default and CLI pairing body share DEFAULT_PAIR_SCOPES', () => {
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'");
expect(pairBlock).toContain('DEFAULT_PAIR_SCOPES');
const cliBlock = sliceBetween(CLI_SRC, 'async function handlePairAgent', 'Determine the URL to use');
expect(cliBlock).toContain('DEFAULT_PAIR_SCOPES');
expect(cliBlock).not.toContain('...(restrict ? { scopes');
});
// control is the only scope behind an explicit flag; a scopes list must
// not be able to smuggle it into a pairing grant.
test('/pair rejects control inside a scopes list without the control flag', () => {
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'");
expect(pairBlock).toContain("pairBody.scopes.includes('control')");
});
// CLI-encoded clientIds (spaces, UTF-8) must round-trip through the revoke
// route; slicing the raw pathname 404s on every encoded name.
test('DELETE /token decodes the clientId path segment', () => {
const revokeBlock = sliceBetween(SERVER_SRC, "url.pathname.startsWith('/token/')", "url.pathname === '/agents'");
expect(revokeBlock).toContain('decodeURIComponent');
});
});
+30
View File
@@ -6,6 +6,7 @@ import {
revokeToken, rotateRoot, listTokens, recordCommand, revokeToken, rotateRoot, listTokens, recordCommand,
serializeRegistry, restoreRegistry, checkConnectRateLimit, serializeRegistry, restoreRegistry, checkConnectRateLimit,
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META, SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META,
DEFAULT_PAIR_SCOPES, InvalidScopeError,
__resetRegistry, __resetRegistry,
} from '../src/token-registry'; } from '../src/token-registry';
@@ -347,6 +348,35 @@ describe('token-registry', () => {
}); });
}); });
describe('pair defaults and option validation', () => {
it('DEFAULT_PAIR_SCOPES is exactly read,write,admin,meta (b73f3644: the ceremony is the trust boundary)', () => {
expect([...DEFAULT_PAIR_SCOPES]).toEqual(['read', 'write', 'admin', 'meta']);
});
// Regression: only createToken validated options, so a scope typo minted
// a poisoned setup key at /pair and surfaced to the REMOTE agent at
// /connect as a misleading "Invalid request body".
it('createSetupKey rejects an unknown scope with InvalidScopeError naming it', () => {
expect(() => createSetupKey({ scopes: ['raed' as never] }))
.toThrow(InvalidScopeError);
expect(() => createSetupKey({ scopes: ['raed' as never] }))
.toThrow('Invalid scope: raed');
});
it('createSetupKey rejects a negative rateLimit', () => {
expect(() => createSetupKey({ rateLimit: -5 })).toThrow(InvalidScopeError);
});
// Regression: `opts.rateLimit || 10` coerced the documented "0 = unlimited"
// into 10 on the /pair path while /token honored it.
it('createSetupKey preserves rateLimit 0 (unlimited)', () => {
const setup = createSetupKey({ rateLimit: 0 });
expect(setup.rateLimit).toBe(0);
const session = exchangeSetupKey(setup.token)!;
expect(session.rateLimit).toBe(0);
});
});
describe('rotateRoot', () => { describe('rotateRoot', () => {
it('generates new root and invalidates all tokens', () => { it('generates new root and invalidates all tokens', () => {
const oldRoot = getRootToken(); const oldRoot = getRootToken();
+44
View File
@@ -97,6 +97,50 @@ describe('tunnel subcommand parsing', () => {
}, 60_000); }, 60_000);
}); });
// pair-agent scope-flag misuse shares this file's subprocess harness: like
// tunnel, the validation must run pre-server, so "no daemon spawned" is the
// load-bearing assertion.
describe('pair-agent scope-flag validation (pre-server)', () => {
test('bare --restrict → exit 1 usage error, NO daemon spawned (was: silent FULL grant)', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-bare-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--restrict'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('--restrict needs a scope list');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('--restrict swallowing the next flag → exit 1, NO daemon spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-flag-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--restrict', '--client', 'bob'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('--restrict needs a scope list');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('--restrict "read,control" → exit 1 pointing at --control, NO daemon spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-ctl-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--restrict', 'read,control'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Re-run with --control');
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)', () => { describe('tunnel against no daemon (#2254 — never boot one)', () => {
test('revoke → exit 0, "No daemon running", nothing spawned', async () => { test('revoke → exit 0, "No daemon running", nothing spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-dead-')); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-dead-'));