mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
feat: integrate token registry + scoped auth into browse server
Server changes for multi-agent browser access: - /connect endpoint: setup key exchange for /pair-agent ceremony - /token endpoint: root-only minting of scoped sub-tokens - /token/:clientId DELETE: revoke agent tokens - /agents endpoint: list connected agents (root-only) - /health: strips root token when tunnel is active (P0 security fix) - /command: scope/rate/domain checks via token registry before dispatch - Idle timer skips shutdown when tunnel is active Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+209
-8
@@ -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<Response> {
|
||||
async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<Response> {
|
||||
const { command, args = [], tabId } = body;
|
||||
|
||||
if (!command) {
|
||||
@@ -780,6 +814,50 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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<string, any> = {
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user