diff --git a/browse/src/server.ts b/browse/src/server.ts index 55b744aa..f8557cf2 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -21,6 +21,12 @@ import { handleCookiePickerRoute } from './cookie-picker-routes'; import { sanitizeExtensionUrl } from './sidebar-utils'; import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands'; import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot'; +import { + initRegistry, validateToken as validateScopedToken, checkScope, checkDomain, + checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken, + rotateRoot, listTokens, serializeRegistry, restoreRegistry, recordCommand, + isRootToken, checkConnectRateLimit, type TokenInfo, +} from './token-registry'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector'; @@ -37,15 +43,41 @@ ensureStateDir(config); // ─── Auth ─────────────────────────────────────────────────────── const AUTH_TOKEN = crypto.randomUUID(); +initRegistry(AUTH_TOKEN); const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min // Sidebar chat is always enabled in headed mode (ungated in v0.12.0) +// ─── Tunnel State ─────────────────────────────────────────────── +let tunnelActive = false; +let tunnelUrl: string | null = null; +let tunnelListener: any = null; // ngrok listener handle + function validateAuth(req: Request): boolean { const header = req.headers.get('authorization'); return header === `Bearer ${AUTH_TOKEN}`; } +/** Extract bearer token from request. Returns the token string or null. */ +function extractToken(req: Request): string | null { + const header = req.headers.get('authorization'); + if (!header?.startsWith('Bearer ')) return null; + return header.slice(7); +} + +/** Validate token and return TokenInfo. Returns null if invalid/expired. */ +function getTokenInfo(req: Request): TokenInfo | null { + const token = extractToken(req); + if (!token) return null; + return validateScopedToken(token); +} + +/** Check if request is from root token (local use). */ +function isRootRequest(req: Request): boolean { + const token = extractToken(req); + return token !== null && isRootToken(token); +} + // ─── Sidebar Model Router ──────────────────────────────────────── // Fast model for navigation/interaction, smart model for reading/analysis. // The delta between sonnet and opus on "click @e24" is 5-10x in latency @@ -678,6 +710,8 @@ const idleCheckInterval = setInterval(() => { // Headed mode: the user is looking at the browser. Never auto-die. // Only shut down when the user explicitly disconnects or closes the window. if (browserManager.getConnectionMode() === 'headed') return; + // Tunnel mode: remote agents may send commands sporadically. Never auto-die. + if (tunnelActive) return; if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`); shutdown(); @@ -770,7 +804,7 @@ function wrapError(err: any): string { return msg; } -async function handleCommand(body: any): Promise { +async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise { const { command, args = [], tabId } = body; if (!command) { @@ -780,6 +814,50 @@ async function handleCommand(body: any): Promise { }); } + // ─── Scope check (for scoped tokens) ────────────────────────── + if (tokenInfo && tokenInfo.clientId !== 'root') { + if (!checkScope(tokenInfo, command)) { + return new Response(JSON.stringify({ + error: `Command "${command}" not allowed by your token scope`, + hint: `Your scopes: ${tokenInfo.scopes.join(', ')}. Ask the user to re-pair with --admin for eval/cookies/storage access.`, + }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Domain check for navigation commands + if (command === 'goto' && args[0]) { + if (!checkDomain(tokenInfo, args[0])) { + return new Response(JSON.stringify({ + error: `Domain not allowed by your token scope`, + hint: `Allowed domains: ${tokenInfo.domains?.join(', ') || 'none configured'}`, + }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // Rate check + const rateResult = checkRate(tokenInfo); + if (!rateResult.allowed) { + return new Response(JSON.stringify({ + error: 'Rate limit exceeded', + hint: `Max ${tokenInfo.rateLimit} requests/second. Retry after ${rateResult.retryAfterMs}ms.`, + }), { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(Math.ceil((rateResult.retryAfterMs || 1000) / 1000)), + }, + }); + } + + // Record command execution for idempotent key exchange tracking + if (tokenInfo.token) recordCommand(tokenInfo.token); + } + // Pin to a specific tab if requested (set by BROWSE_TAB env var in sidebar agents). // This prevents parallel agents from interfering with each other's tab context. // Safe because Bun's event loop is single-threaded — no concurrent handleCommand. @@ -1080,16 +1158,12 @@ async function start() { // Health check — no auth required, does NOT reset idle timer if (url.pathname === '/health') { const healthy = await browserManager.isHealthy(); - return new Response(JSON.stringify({ + const healthResponse: Record = { status: healthy ? 'healthy' : 'unhealthy', mode: browserManager.getConnectionMode(), uptime: Math.floor((Date.now() - startTime) / 1000), tabs: browserManager.getTabCount(), currentUrl: browserManager.getCurrentUrl(), - // Auth token for extension bootstrap. Safe: /health is localhost-only. - // Previously served via .auth.json in extension dir, but that breaks - // read-only .app bundles and codesigning. Extension reads token from here. - token: AUTH_TOKEN, chatEnabled: true, agent: { status: agentStatus, @@ -1098,12 +1172,131 @@ async function start() { queueLength: messageQueue.length, }, session: sidebarSession ? { id: sidebarSession.id, name: sidebarSession.name } : null, - }), { + }; + // Auth token for extension bootstrap. ONLY when not tunneled. + // When tunneled, /health is reachable from the internet. Exposing the + // root token here would let anyone bypass the pairing ceremony. + if (!tunnelActive) { + healthResponse.token = AUTH_TOKEN; + } + if (tunnelActive) { + healthResponse.tunnel = { url: tunnelUrl, active: true }; + } + return new Response(JSON.stringify(healthResponse), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } + // ─── /connect — setup key exchange for /pair-agent ceremony ──── + if (url.pathname === '/connect' && req.method === 'POST') { + if (!checkConnectRateLimit()) { + return new Response(JSON.stringify({ + error: 'Too many connection attempts. Wait 1 minute.', + }), { status: 429, headers: { 'Content-Type': 'application/json' } }); + } + try { + const connectBody = await req.json() as { setup_key?: string }; + if (!connectBody.setup_key) { + return new Response(JSON.stringify({ error: 'Missing setup_key' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + const session = exchangeSetupKey(connectBody.setup_key); + if (!session) { + return new Response(JSON.stringify({ + error: 'Invalid, expired, or already-used setup key', + }), { status: 401, headers: { 'Content-Type': 'application/json' } }); + } + console.log(`[browse] Remote agent connected: ${session.clientId} (scopes: ${session.scopes.join(',')})`); + return new Response(JSON.stringify({ + token: session.token, + expires: session.expiresAt, + scopes: session.scopes, + agent: session.clientId, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch { + return new Response(JSON.stringify({ error: 'Invalid request body' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // ─── /token — mint scoped tokens (root-only) ────────────────── + if (url.pathname === '/token' && req.method === 'POST') { + if (!isRootRequest(req)) { + return new Response(JSON.stringify({ + error: 'Only the root token can mint sub-tokens', + }), { status: 403, headers: { 'Content-Type': 'application/json' } }); + } + try { + const tokenBody = await req.json() as any; + if (!tokenBody.clientId) { + return new Response(JSON.stringify({ error: 'Missing clientId' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + const session = createToken({ + clientId: tokenBody.clientId, + scopes: tokenBody.scopes, + domains: tokenBody.domains, + tabPolicy: tokenBody.tabPolicy, + rateLimit: tokenBody.rateLimit, + expiresSeconds: tokenBody.expiresSeconds, + }); + return new Response(JSON.stringify({ + token: session.token, + expires: session.expiresAt, + scopes: session.scopes, + agent: session.clientId, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch { + return new Response(JSON.stringify({ error: 'Invalid request body' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // ─── /token/:clientId — revoke a scoped token (root-only) ───── + if (url.pathname.startsWith('/token/') && req.method === 'DELETE') { + if (!isRootRequest(req)) { + return new Response(JSON.stringify({ error: 'Root token required' }), { + status: 403, headers: { 'Content-Type': 'application/json' }, + }); + } + const clientId = url.pathname.slice('/token/'.length); + const revoked = revokeToken(clientId); + if (!revoked) { + return new Response(JSON.stringify({ error: `Agent "${clientId}" not found` }), { + status: 404, headers: { 'Content-Type': 'application/json' }, + }); + } + console.log(`[browse] Revoked token for: ${clientId}`); + return new Response(JSON.stringify({ revoked: clientId }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + + // ─── /agents — list connected agents (root-only) ────────────── + if (url.pathname === '/agents' && req.method === 'GET') { + if (!isRootRequest(req)) { + return new Response(JSON.stringify({ error: 'Root token required' }), { + status: 403, headers: { 'Content-Type': 'application/json' }, + }); + } + const agents = listTokens().map(t => ({ + clientId: t.clientId, + scopes: t.scopes, + domains: t.domains, + expiresAt: t.expiresAt, + commandCount: t.commandCount, + createdAt: t.createdAt, + })); + return new Response(JSON.stringify({ agents }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + // Refs endpoint — auth required, does NOT reset idle timer if (url.pathname === '/refs') { if (!validateAuth(req)) { @@ -1608,9 +1801,17 @@ async function start() { // ─── Command endpoint ────────────────────────────────────────── if (url.pathname === '/command' && req.method === 'POST') { + // Accept both root token and scoped tokens + const tokenInfo = getTokenInfo(req); + if (!tokenInfo) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } resetIdleTimer(); // Only commands reset idle timer const body = await req.json(); - return handleCommand(body); + return handleCommand(body, tokenInfo); } return new Response('Not found', { status: 404 });