diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 31a1f9de..838db04f 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -61,6 +61,26 @@ export class BrowserManager { private isHeaded: boolean = false; private consecutiveFailures: number = 0; + // ─── CDP State ──────────────────────────────────────────── + private connectionMode: 'launched' | 'cdp' = 'launched'; + private preExistingTabIds: Set = new Set(); + private cdpPort: number = 0; + private intentionalDisconnect = false; + private reconnecting = false; + + getConnectionMode(): 'launched' | 'cdp' { return this.connectionMode; } + + /** + * Get the ref map for external consumers (e.g., /refs endpoint). + */ + getRefMap(): Array<{ ref: string; role: string; name: string }> { + const refs: Array<{ ref: string; role: string; name: string }> = []; + for (const [ref, entry] of this.refMap) { + refs.push({ ref, role: entry.role, name: entry.name }); + } + return refs; + } + async launch() { this.browser = await chromium.launch({ headless: true }); @@ -87,15 +107,110 @@ export class BrowserManager { await this.newTab(); } + // ─── CDP Connect ──────────────────────────────────────────── + /** + * Connect to a running browser via Chrome DevTools Protocol. + * All existing commands work unchanged through Playwright's abstraction. + * + * CDP flow: + * connectOverCDP(wsUrl) → Browser → contexts()[0] → discover pages + * Disconnect handler → attemptReconnect() (not process.exit) + * close() → browser.disconnect() (not browser.close()) + */ + async connectCDP(wsUrl: string, port: number): Promise { + // Clear old state before repopulating (safe for reconnect) + this.pages.clear(); + this.preExistingTabIds.clear(); + this.refMap.clear(); + this.nextTabId = 1; + + this.browser = await chromium.connectOverCDP(wsUrl); + this.connectionMode = 'cdp'; + this.cdpPort = port; + this.intentionalDisconnect = false; + + // Use the user's existing default context (has their cookies, sessions) + const contexts = this.browser.contexts(); + if (contexts.length === 0) { + throw new Error('No browser context found. Chrome may have no windows open.'); + } + this.context = contexts[0]; + + // Discover existing tabs + for (const page of this.context.pages()) { + const id = this.nextTabId++; + this.pages.set(id, page); + this.preExistingTabIds.add(id); + this.wirePageEvents(page); + } + this.activeTabId = [...this.pages.keys()].pop() || 0; + + // Listen for new tabs created by the user + this.context.on('page', (page: Page) => { + const id = this.nextTabId++; + this.pages.set(id, page); + this.wirePageEvents(page); + this.activeTabId = id; + }); + + // CDP disconnect ≠ crash — reconnect unless intentional + this.browser.on('disconnected', () => { + if (this.intentionalDisconnect) return; + console.log('[browse] Real browser disconnected — reconnecting...'); + this.attemptReconnect(); + }); + + // CDP-specific defaults + this.dialogAutoAccept = false; // Don't dismiss user's real dialogs + this.isHeaded = true; + this.consecutiveFailures = 0; + } + + /** + * Auto-reconnect after unexpected CDP disconnect (e.g., browser restart). + * Non-blocking recursive setTimeout — never overlaps or blocks commands. + */ + private async attemptReconnect(remaining = 60): Promise { + if (remaining <= 0 || this.reconnecting || this.intentionalDisconnect) { + if (remaining <= 0) { + console.log('[browse] CDP reconnect failed after 5 minutes. Run `$B connect` to reconnect.'); + } + return; + } + + this.reconnecting = true; + try { + const { isCdpAvailable } = await import('./chrome-launcher'); + const result = await isCdpAvailable(this.cdpPort); + if (result.available && result.wsUrl) { + await this.connectCDP(result.wsUrl, this.cdpPort); + console.log('[browse] Reconnected to real browser'); + return; + } + } catch { + // Probe failed — try again + } finally { + this.reconnecting = false; + } + + setTimeout(() => this.attemptReconnect(remaining - 1), 5000); + } + async close() { if (this.browser) { - // Remove disconnect handler to avoid exit during intentional close - this.browser.removeAllListeners('disconnected'); - // Timeout: headed browser.close() can hang on macOS - await Promise.race([ - this.browser.close(), - new Promise(resolve => setTimeout(resolve, 5000)), - ]).catch(() => {}); + if (this.connectionMode === 'cdp') { + // CDP mode: disconnect (don't kill user's browser) + this.intentionalDisconnect = true; + this.browser.removeAllListeners('disconnected'); + await this.browser.disconnect().catch(() => {}); + } else { + // Launched mode: close the browser we spawned + this.browser.removeAllListeners('disconnected'); + await Promise.race([ + this.browser.close(), + new Promise(resolve => setTimeout(resolve, 5000)), + ]).catch(() => {}); + } this.browser = null; } } @@ -145,6 +260,11 @@ export class BrowserManager { const page = this.pages.get(tabId); if (!page) throw new Error(`Tab ${tabId} not found`); + // CDP mode: block closing pre-existing user tabs + if (this.connectionMode === 'cdp' && this.preExistingTabIds.has(tabId)) { + throw new Error("Cannot close user's pre-existing tab in real-browser mode. Only tabs created by gstack can be closed."); + } + await page.close(); this.pages.delete(tabId); @@ -384,6 +504,9 @@ export class BrowserManager { * Falls back to a clean slate on any failure. */ async recreateContext(): Promise { + if (this.connectionMode === 'cdp') { + throw new Error('Cannot recreate context in real-browser mode. The browser context belongs to the user.'); + } if (!this.browser || !this.context) { throw new Error('Browser not launched'); } @@ -450,6 +573,9 @@ export class BrowserManager { * If step 2 fails → return error, headless browser untouched */ async handoff(message: string): Promise { + if (this.connectionMode === 'cdp') { + return 'Already controlling real browser via CDP. No handoff needed.'; + } if (this.isHeaded) { return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`; } diff --git a/browse/src/chrome-launcher.ts b/browse/src/chrome-launcher.ts new file mode 100644 index 00000000..769b390d --- /dev/null +++ b/browse/src/chrome-launcher.ts @@ -0,0 +1,238 @@ +/** + * Chrome/Comet browser discovery + CDP connection + * + * Discovery flow (macOS only): + * 1. Probe localhost:9222 for existing CDP endpoint + * 2. If occupied by non-Chrome, try 9223-9225 + * 3. If no CDP: find browser binary, quit gracefully, relaunch with --remote-debugging-port + * 4. On attach failure: rollback — relaunch browser WITHOUT debug flag + * + * Reuses the browser registry pattern from cookie-import-browser.ts + */ + +import { execSync, spawn } from 'child_process'; + +// ─── Browser Binary Registry (macOS) ─────────────────────────── + +export interface BrowserBinary { + name: string; + binary: string; + appName: string; // for osascript 'tell application "X"' + aliases: string[]; +} + +export const BROWSER_BINARIES: BrowserBinary[] = [ + { name: 'Comet', binary: '/Applications/Comet.app/Contents/MacOS/Comet', appName: 'Comet', aliases: ['comet', 'perplexity'] }, + { name: 'Chrome', binary: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', appName: 'Google Chrome', aliases: ['chrome', 'google-chrome'] }, + { name: 'Arc', binary: '/Applications/Arc.app/Contents/MacOS/Arc', appName: 'Arc', aliases: ['arc'] }, + { name: 'Brave', binary: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', appName: 'Brave Browser', aliases: ['brave'] }, + { name: 'Edge', binary: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', appName: 'Microsoft Edge', aliases: ['edge'] }, +]; + +// ─── CDP Probe ───────────────────────────────────────────────── + +export interface CdpProbeResult { + available: boolean; + wsUrl?: string; + browser?: string; +} + +/** + * Check if a CDP endpoint is available at the given port. + * Returns the WebSocket debugger URL if found. + */ +export async function isCdpAvailable(port: number): Promise { + try { + const resp = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: AbortSignal.timeout(2000), + }); + if (!resp.ok) return { available: false }; + const data = await resp.json() as Record; + const wsUrl = data.webSocketDebuggerUrl; + if (!wsUrl) return { available: false }; + return { available: true, wsUrl, browser: data.Browser }; + } catch { + return { available: false }; + } +} + +/** + * Get the WebSocket debugger URL from a CDP port. + * Throws if not available. + */ +export async function getCdpWebSocketUrl(port: number): Promise { + const result = await isCdpAvailable(port); + if (!result.available || !result.wsUrl) { + throw new Error(`No CDP endpoint at port ${port}`); + } + return result.wsUrl; +} + +/** + * Try ports 9222-9225 to find an available CDP endpoint. + */ +export async function findCdpPort(): Promise<{ port: number; wsUrl: string; browser?: string } | null> { + for (const port of [9222, 9223, 9224, 9225]) { + const result = await isCdpAvailable(port); + if (result.available && result.wsUrl) { + return { port, wsUrl: result.wsUrl, browser: result.browser }; + } + } + return null; +} + +// ─── Browser Binary Discovery ────────────────────────────────── + +import * as fs from 'fs'; + +/** + * Find the binary path for a browser by name or alias. + */ +export function findBrowserBinary(nameOrAlias: string): BrowserBinary | null { + const needle = nameOrAlias.toLowerCase(); + return BROWSER_BINARIES.find(b => + b.aliases.includes(needle) || b.name.toLowerCase() === needle + ) ?? null; +} + +/** + * Find installed browsers (binary exists on disk). + */ +export function findInstalledBrowsers(): BrowserBinary[] { + return BROWSER_BINARIES.filter(b => { + try { return fs.existsSync(b.binary); } catch { return false; } + }); +} + +/** + * Check if a browser is currently running (macOS: pgrep). + */ +export function isBrowserRunning(browser: BrowserBinary): boolean { + try { + // Use the app name to find the process + execSync(`pgrep -f "${browser.appName}"`, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +// ─── Browser Launch with CDP ─────────────────────────────────── + +/** + * Quit a browser gracefully via osascript and relaunch with --remote-debugging-port. + * Returns the CDP WebSocket URL on success. + * + * If the user's browser is running, this will: + * 1. Quit it gracefully (tabs restored on relaunch) + * 2. Wait 2s for clean shutdown + * 3. Relaunch with --remote-debugging-port + * 4. Poll for CDP availability (up to 15s) + * + * On failure: attempt to relaunch WITHOUT debug flag (rollback). + */ +export async function launchWithCdp( + browser: BrowserBinary, + port: number = 9222, +): Promise<{ wsUrl: string; port: number }> { + const wasRunning = isBrowserRunning(browser); + + if (wasRunning) { + // Quit gracefully via osascript + try { + execSync(`osascript -e 'tell application "${browser.appName}" to quit'`, { + stdio: 'pipe', + timeout: 10000, + }); + } catch { + throw new Error(`Failed to quit ${browser.name}. Close it manually and try again.`); + } + // Wait for clean shutdown + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + // Relaunch with CDP flag + const child = spawn(browser.binary, [ + `--remote-debugging-port=${port}`, + '--restore-last-session', + ], { + detached: true, + stdio: 'ignore', + }); + child.unref(); + + // Poll for CDP availability (up to 15s) + const startTime = Date.now(); + while (Date.now() - startTime < 15000) { + const result = await isCdpAvailable(port); + if (result.available && result.wsUrl) { + return { wsUrl: result.wsUrl, port }; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Rollback: relaunch without debug flag so user gets their browser back + if (wasRunning) { + try { + const rollback = spawn(browser.binary, ['--restore-last-session'], { + detached: true, + stdio: 'ignore', + }); + rollback.unref(); + } catch {} + } + + throw new Error( + `CDP endpoint not available after 15s. ${browser.name} may not support --remote-debugging-port, ` + + `or port ${port} is blocked. Browser has been relaunched without debug flag.` + ); +} + +/** + * Full discovery algorithm: + * 1. Check for existing CDP on ports 9222-9225 + * 2. Find an installed browser (priority order) + * 3. Launch/relaunch with CDP + * + * @param preferredBrowser - Optional browser name (e.g., 'chrome', 'comet') + * @param port - CDP port (default 9222) + */ +export async function discoverAndConnect( + preferredBrowser?: string, + port: number = 9222, +): Promise<{ wsUrl: string; port: number; browser: string }> { + // Step 1: Check for existing CDP + const existing = await findCdpPort(); + if (existing) { + return { + wsUrl: existing.wsUrl, + port: existing.port, + browser: existing.browser || 'Unknown', + }; + } + + // Step 2: Find browser binary + let browser: BrowserBinary | null = null; + + if (preferredBrowser) { + browser = findBrowserBinary(preferredBrowser); + if (!browser) { + const installed = findInstalledBrowsers(); + const names = installed.map(b => b.name.toLowerCase()).join(', '); + throw new Error( + `Browser '${preferredBrowser}' not found. Installed: ${names || 'none'}` + ); + } + } else { + // Auto-detect: first installed browser in priority order + const installed = findInstalledBrowsers(); + if (installed.length === 0) { + throw new Error('No supported browser found. Install Chrome, Comet, Arc, Brave, or Edge.'); + } + browser = installed[0]; + } + + // Step 3: Launch with CDP + const result = await launchWithCdp(browser, port); + return { ...result, browser: browser.name }; +} diff --git a/browse/src/server.ts b/browse/src/server.ts index 82af28bd..7607d120 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -21,6 +21,7 @@ import { handleCookiePickerRoute } from './cookie-picker-routes'; import { COMMAND_DESCRIPTIONS } from './commands'; import { SNAPSHOT_FLAGS } from './snapshot'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; +import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -224,6 +225,17 @@ async function handleCommand(body: any): Promise { }); } + // Activity: emit command_start + const startTime = Date.now(); + emitActivity({ + type: 'command_start', + command, + args, + url: browserManager.getCurrentUrl(), + tabs: browserManager.getTabCount(), + mode: browserManager.getConnectionMode(), + }); + try { let result: string; @@ -249,12 +261,38 @@ async function handleCommand(body: any): Promise { }); } + // Activity: emit command_end (success) + emitActivity({ + type: 'command_end', + command, + args, + url: browserManager.getCurrentUrl(), + duration: Date.now() - startTime, + status: 'ok', + result: result, + tabs: browserManager.getTabCount(), + mode: browserManager.getConnectionMode(), + }); + browserManager.resetFailures(); return new Response(result, { status: 200, headers: { 'Content-Type': 'text/plain' }, }); } catch (err: any) { + // Activity: emit command_end (error) + emitActivity({ + type: 'command_end', + command, + args, + url: browserManager.getCurrentUrl(), + duration: Date.now() - startTime, + status: 'error', + error: err.message, + tabs: browserManager.getTabCount(), + mode: browserManager.getConnectionMode(), + }); + browserManager.incrementFailures(); let errorMsg = wrapError(err); const hint = browserManager.getFailureHint(); @@ -296,16 +334,21 @@ async function start() { const port = await findPort(); - // Launch browser - await browserManager.launch(); + // Launch browser (or connect to existing via CDP) + const cdpUrl = process.env.BROWSE_CDP_URL; + const cdpPort = parseInt(process.env.BROWSE_CDP_PORT || '0', 10); + if (cdpUrl) { + await browserManager.connectCDP(cdpUrl, cdpPort); + console.log(`[browse] Connected to real browser via CDP (port ${cdpPort})`); + } else { + await browserManager.launch(); + } const startTime = Date.now(); const server = Bun.serve({ port, hostname: '127.0.0.1', fetch: async (req) => { - resetIdleTimer(); - const url = new URL(req.url); // Cookie picker routes — no auth required (localhost-only) @@ -313,11 +356,12 @@ async function start() { return handleCookiePickerRoute(url, req, browserManager); } - // Health check — no auth required (now async) + // Health check — no auth required, does NOT reset idle timer if (url.pathname === '/health') { const healthy = await browserManager.isHealthy(); return new Response(JSON.stringify({ status: healthy ? 'healthy' : 'unhealthy', + mode: browserManager.getConnectionMode(), uptime: Math.floor((Date.now() - startTime) / 1000), tabs: browserManager.getTabCount(), currentUrl: browserManager.getCurrentUrl(), @@ -327,6 +371,89 @@ async function start() { }); } + // Refs endpoint — no auth required (localhost-only), does NOT reset idle timer + if (url.pathname === '/refs') { + const refs = browserManager.getRefMap(); + return new Response(JSON.stringify({ + refs, + url: browserManager.getCurrentUrl(), + mode: browserManager.getConnectionMode(), + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + // Activity stream — SSE, no auth (localhost-only), does NOT reset idle timer + if (url.pathname === '/activity/stream') { + const afterId = parseInt(url.searchParams.get('after') || '0', 10); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + // 1. Gap detection + replay + const { entries, gap, gapFrom, availableFrom } = getActivityAfter(afterId); + if (gap) { + controller.enqueue(encoder.encode(`event: gap\ndata: ${JSON.stringify({ gapFrom, availableFrom })}\n\n`)); + } + for (const entry of entries) { + controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`)); + } + + // 2. Subscribe for live events + const unsubscribe = subscribe((entry) => { + try { + controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`)); + } catch { + unsubscribe(); + } + }); + + // 3. Heartbeat every 15s + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: heartbeat\n\n`)); + } catch { + clearInterval(heartbeat); + unsubscribe(); + } + }, 15000); + + // 4. Cleanup on disconnect + req.signal.addEventListener('abort', () => { + clearInterval(heartbeat); + unsubscribe(); + try { controller.close(); } catch {} + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + // Activity history — REST, no auth (localhost-only), does NOT reset idle timer + if (url.pathname === '/activity/history') { + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + const { entries, totalAdded } = getActivityHistory(limit); + return new Response(JSON.stringify({ entries, totalAdded, subscribers: getSubscriberCount() }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + // All other endpoints require auth if (!validateAuth(req)) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { @@ -336,6 +463,7 @@ async function start() { } if (url.pathname === '/command' && req.method === 'POST') { + resetIdleTimer(); // Only commands reset idle timer const body = await req.json(); return handleCommand(body); } @@ -345,13 +473,15 @@ async function start() { }); // Write state file (atomic: write .tmp then rename) - const state = { + const state: Record = { pid: process.pid, port, token: AUTH_TOKEN, startedAt: new Date().toISOString(), serverPath: path.resolve(import.meta.dir, 'server.ts'), binaryVersion: readVersionHash() || undefined, + mode: browserManager.getConnectionMode(), + ...(cdpPort ? { cdpPort } : {}), }; const tmpFile = config.stateFile + '.tmp'; fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); diff --git a/browse/test/cdp-connect.test.ts b/browse/test/cdp-connect.test.ts new file mode 100644 index 00000000..8d78e4a0 --- /dev/null +++ b/browse/test/cdp-connect.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, mock, beforeEach } from 'bun:test'; +import { + findBrowserBinary, + findInstalledBrowsers, + isCdpAvailable, + getCdpWebSocketUrl, + findCdpPort, + BROWSER_BINARIES, +} from '../src/chrome-launcher'; + +// ─── chrome-launcher unit tests ───────────────────────────────── + +describe('findBrowserBinary', () => { + it('finds Chrome by alias', () => { + const result = findBrowserBinary('chrome'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('Chrome'); + }); + + it('finds Chrome by name (case-insensitive)', () => { + const result = findBrowserBinary('Chrome'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('Chrome'); + }); + + it('finds Comet by alias', () => { + const result = findBrowserBinary('comet'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('Comet'); + }); + + it('finds Comet by perplexity alias', () => { + const result = findBrowserBinary('perplexity'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('Comet'); + }); + + it('returns null for unknown browser', () => { + expect(findBrowserBinary('netscape')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(findBrowserBinary('')).toBeNull(); + }); +}); + +describe('BROWSER_BINARIES', () => { + it('has correct priority order (Comet first)', () => { + expect(BROWSER_BINARIES[0].name).toBe('Comet'); + expect(BROWSER_BINARIES[1].name).toBe('Chrome'); + }); + + it('all entries have required fields', () => { + for (const browser of BROWSER_BINARIES) { + expect(browser.name).toBeTruthy(); + expect(browser.binary).toContain('/Applications/'); + expect(browser.appName).toBeTruthy(); + expect(browser.aliases.length).toBeGreaterThan(0); + } + }); +}); + +describe('isCdpAvailable', () => { + it('returns false for port with no listener', async () => { + // Port 19999 should not have anything listening + const result = await isCdpAvailable(19999); + expect(result.available).toBe(false); + expect(result.wsUrl).toBeUndefined(); + }); + + it('returns false for invalid port', async () => { + const result = await isCdpAvailable(0); + expect(result.available).toBe(false); + }); +}); + +describe('getCdpWebSocketUrl', () => { + it('throws for unavailable port', async () => { + await expect(getCdpWebSocketUrl(19999)).rejects.toThrow('No CDP endpoint'); + }); +}); + +describe('findCdpPort', () => { + it('returns null when no CDP ports are available', async () => { + // This test passes in CI where no Chrome is running with debug port + // In local dev with debug port open, it would find one + const result = await findCdpPort(); + // Either null (no CDP) or valid result — both are correct + if (result !== null) { + expect(result.port).toBeGreaterThan(0); + expect(result.wsUrl).toContain('ws://'); + } + }); +}); + +// ─── BrowserManager CDP mode guards ───────────────────────────── + +describe('BrowserManager CDP mode', () => { + // These tests verify the mode guard logic without actually connecting + // to a real browser. We test the public interface. + + it('getConnectionMode defaults to launched', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + expect(bm.getConnectionMode()).toBe('launched'); + }); + + it('getRefMap returns empty array initially', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + expect(bm.getRefMap()).toEqual([]); + }); +});