cdp.ts
1 /** 2 * CDP client — implements IPage by connecting directly to a Chrome/Electron CDP WebSocket. 3 * 4 * Fixes applied: 5 * - send() now has a 30s timeout guard (P0 #4) 6 * - goto() waits for Page.loadEventFired instead of hardcoded 1s sleep (P1 #3) 7 * - Implemented scroll, autoScroll, screenshot, networkRequests (P1 #2) 8 * - Shared DOM helper methods extracted to reduce duplication with Page (P1 #5) 9 */ 10 11 import { WebSocket, type RawData } from 'ws'; 12 import { request as httpRequest } from 'node:http'; 13 import { request as httpsRequest } from 'node:https'; 14 import type { BrowserCookie, IPage, ScreenshotOptions } from '../types.js'; 15 import type { IBrowserFactory } from '../runtime.js'; 16 import { wrapForEval } from './utils.js'; 17 import { generateStealthJs } from './stealth.js'; 18 import { waitForDomStableJs } from './dom-helpers.js'; 19 import { isRecord, saveBase64ToFile } from '../utils.js'; 20 import { getAllElectronApps } from '../electron-apps.js'; 21 import { BasePage } from './base-page.js'; 22 23 export interface CDPTarget { 24 type?: string; 25 url?: string; 26 title?: string; 27 webSocketDebuggerUrl?: string; 28 } 29 30 interface RuntimeEvaluateResult { 31 result?: { 32 value?: unknown; 33 }; 34 exceptionDetails?: { 35 exception?: { 36 description?: string; 37 }; 38 }; 39 } 40 41 const CDP_SEND_TIMEOUT = 30_000; 42 43 // Memory guard for in-process capture. The 4k cap we used to apply everywhere 44 // silently truncated JSON so `JSON.parse` failed or gave partial objects — the 45 // primary agent-facing bug. Now we keep the full body up to a large cap and 46 // surface `responseBodyFullSize` + `responseBodyTruncated` so downstream layers 47 // can tell the agent what happened instead of lying about the payload. 48 export const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; 49 50 export class CDPBridge implements IBrowserFactory { 51 private _ws: WebSocket | null = null; 52 private _idCounter = 0; 53 private _pending = new Map<number, { resolve: (val: unknown) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>(); 54 private _eventListeners = new Map<string, Set<(params: unknown) => void>>(); 55 56 async connect(opts?: { timeout?: number; workspace?: string; cdpEndpoint?: string }): Promise<IPage> { 57 if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.'); 58 59 const endpoint = opts?.cdpEndpoint ?? process.env.OPENCLI_CDP_ENDPOINT; 60 if (!endpoint) throw new Error('CDP endpoint not provided (pass cdpEndpoint or set OPENCLI_CDP_ENDPOINT)'); 61 62 let wsUrl = endpoint; 63 if (endpoint.startsWith('http')) { 64 const targets = await fetchJsonDirect(`${endpoint.replace(/\/$/, '')}/json`) as CDPTarget[]; 65 const target = selectCDPTarget(targets); 66 if (!target || !target.webSocketDebuggerUrl) { 67 throw new Error('No inspectable targets found at CDP endpoint'); 68 } 69 wsUrl = target.webSocketDebuggerUrl; 70 } 71 72 return new Promise((resolve, reject) => { 73 const ws = new WebSocket(wsUrl); 74 const timeoutMs = (opts?.timeout ?? 10) * 1000; 75 const timeout = setTimeout(() => { 76 this._ws = null; 77 ws.close(); 78 reject(new Error('CDP connect timeout')); 79 }, timeoutMs); 80 81 ws.on('open', async () => { 82 clearTimeout(timeout); 83 this._ws = ws; 84 try { 85 await this.send('Page.enable'); 86 await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() }); 87 } catch (err) { 88 ws.close(); 89 reject(err instanceof Error ? err : new Error(String(err))); 90 return; 91 } 92 resolve(new CDPPage(this)); 93 }); 94 95 ws.on('error', (err: Error) => { 96 clearTimeout(timeout); 97 reject(err); 98 }); 99 100 ws.on('message', (data: RawData) => { 101 try { 102 const msg = JSON.parse(data.toString()); 103 if (msg.id && this._pending.has(msg.id)) { 104 const entry = this._pending.get(msg.id)!; 105 clearTimeout(entry.timer); 106 this._pending.delete(msg.id); 107 if (msg.error) { 108 entry.reject(new Error(msg.error.message)); 109 } else { 110 entry.resolve(msg.result); 111 } 112 } 113 if (msg.method) { 114 const listeners = this._eventListeners.get(msg.method); 115 if (listeners) { 116 for (const fn of listeners) fn(msg.params); 117 } 118 } 119 } catch (err) { 120 if (process.env.OPENCLI_VERBOSE) { 121 // eslint-disable-next-line no-console 122 console.error('[cdp] Failed to parse WebSocket message:', err instanceof Error ? err.message : err); 123 } 124 } 125 }); 126 }); 127 } 128 129 async close(): Promise<void> { 130 if (this._ws) { 131 this._ws.close(); 132 this._ws = null; 133 } 134 for (const p of this._pending.values()) { 135 clearTimeout(p.timer); 136 p.reject(new Error('CDP connection closed')); 137 } 138 this._pending.clear(); 139 this._eventListeners.clear(); 140 } 141 142 async send(method: string, params: Record<string, unknown> = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<unknown> { 143 if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { 144 throw new Error('CDP connection is not open'); 145 } 146 const id = ++this._idCounter; 147 return new Promise((resolve, reject) => { 148 const timer = setTimeout(() => { 149 this._pending.delete(id); 150 reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`)); 151 }, timeoutMs); 152 this._pending.set(id, { resolve, reject, timer }); 153 this._ws!.send(JSON.stringify({ id, method, params })); 154 }); 155 } 156 157 on(event: string, handler: (params: unknown) => void): void { 158 let set = this._eventListeners.get(event); 159 if (!set) { 160 set = new Set(); 161 this._eventListeners.set(event, set); 162 } 163 set.add(handler); 164 } 165 166 off(event: string, handler: (params: unknown) => void): void { 167 this._eventListeners.get(event)?.delete(handler); 168 } 169 170 waitForEvent(event: string, timeoutMs: number = 15_000): Promise<unknown> { 171 return new Promise((resolve, reject) => { 172 const timer = setTimeout(() => { 173 this.off(event, handler); 174 reject(new Error(`Timed out waiting for CDP event '${event}'`)); 175 }, timeoutMs); 176 const handler = (params: unknown) => { 177 clearTimeout(timer); 178 this.off(event, handler); 179 resolve(params); 180 }; 181 this.on(event, handler); 182 }); 183 } 184 } 185 186 class CDPPage extends BasePage { 187 private _pageEnabled = false; 188 189 // Network capture state (mirrors extension/src/cdp.ts NetworkCaptureEntry shape) 190 private _networkCapturing = false; 191 private _networkCapturePattern = ''; 192 private _networkEntries: Array<{ 193 url: string; method: string; responseStatus?: number; 194 responseContentType?: string; 195 responsePreview?: string; 196 responseBodyFullSize?: number; 197 responseBodyTruncated?: boolean; 198 timestamp: number; 199 }> = []; 200 private _pendingRequests = new Map<string, number>(); // requestId → index in _networkEntries 201 private _pendingBodyFetches: Set<Promise<void>> = new Set(); // track in-flight getResponseBody calls 202 private _consoleMessages: Array<{ type: string; text: string; timestamp: number }> = []; 203 private _consoleCapturing = false; 204 205 constructor(private bridge: CDPBridge) { 206 super(); 207 } 208 209 async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> { 210 if (!this._pageEnabled) { 211 await this.bridge.send('Page.enable'); 212 this._pageEnabled = true; 213 } 214 const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => {}); 215 await this.bridge.send('Page.navigate', { url }); 216 await loadPromise; 217 this._lastUrl = url; 218 if (options?.waitUntil !== 'none') { 219 const maxMs = options?.settleMs ?? 1000; 220 await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs))); 221 } 222 } 223 224 async evaluate(js: string): Promise<unknown> { 225 const expression = wrapForEval(js); 226 const result = await this.bridge.send('Runtime.evaluate', { 227 expression, 228 returnByValue: true, 229 awaitPromise: true, 230 }) as RuntimeEvaluateResult; 231 if (result.exceptionDetails) { 232 throw new Error('Evaluate error: ' + (result.exceptionDetails.exception?.description || 'Unknown exception')); 233 } 234 return result.result?.value; 235 } 236 237 async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> { 238 const result = await this.bridge.send('Network.getCookies', opts.url ? { urls: [opts.url] } : {}); 239 const cookies = isRecord(result) && Array.isArray(result.cookies) ? result.cookies : []; 240 const domain = opts.domain; 241 return domain 242 ? cookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && matchesCookieDomain(cookie.domain, domain)) 243 : cookies; 244 } 245 246 async screenshot(options: ScreenshotOptions = {}): Promise<string> { 247 const result = await this.bridge.send('Page.captureScreenshot', { 248 format: options.format ?? 'png', 249 quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined, 250 captureBeyondViewport: options.fullPage ?? false, 251 }); 252 const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : ''; 253 if (options.path) { 254 await saveBase64ToFile(base64, options.path); 255 } 256 return base64; 257 } 258 259 async startNetworkCapture(pattern: string = ''): Promise<boolean> { 260 // Always update the filter pattern 261 this._networkCapturePattern = pattern; 262 263 // Reset state only on first start; avoid wiping entries if already capturing 264 if (!this._networkCapturing) { 265 this._networkEntries = []; 266 this._pendingRequests.clear(); 267 this._pendingBodyFetches.clear(); 268 await this.bridge.send('Network.enable'); 269 270 // Step 1: Record request method/url on requestWillBeSent 271 this.bridge.on('Network.requestWillBeSent', (params: unknown) => { 272 const p = params as { requestId: string; request: { method: string; url: string }; timestamp: number }; 273 if (!this._networkCapturePattern || p.request.url.includes(this._networkCapturePattern)) { 274 const idx = this._networkEntries.push({ 275 url: p.request.url, 276 method: p.request.method, 277 timestamp: p.timestamp, 278 }) - 1; 279 this._pendingRequests.set(p.requestId, idx); 280 } 281 }); 282 283 // Step 2: Fill in response metadata on responseReceived 284 this.bridge.on('Network.responseReceived', (params: unknown) => { 285 const p = params as { requestId: string; response: { status: number; mimeType?: string } }; 286 const idx = this._pendingRequests.get(p.requestId); 287 if (idx !== undefined) { 288 this._networkEntries[idx].responseStatus = p.response.status; 289 this._networkEntries[idx].responseContentType = p.response.mimeType || ''; 290 } 291 }); 292 293 // Step 3: Fetch body on loadingFinished (body is only reliably available after this) 294 this.bridge.on('Network.loadingFinished', (params: unknown) => { 295 const p = params as { requestId: string }; 296 const idx = this._pendingRequests.get(p.requestId); 297 if (idx !== undefined) { 298 const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result: unknown) => { 299 const r = result as { body?: string; base64Encoded?: boolean } | undefined; 300 if (typeof r?.body === 'string') { 301 const fullSize = r.body.length; 302 const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT; 303 const body = truncated ? r.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : r.body; 304 this._networkEntries[idx].responsePreview = r.base64Encoded ? `base64:${body}` : body; 305 this._networkEntries[idx].responseBodyFullSize = fullSize; 306 this._networkEntries[idx].responseBodyTruncated = truncated; 307 } 308 }).catch((err) => { 309 // Body unavailable for some requests (e.g. uploads) — non-fatal 310 if (process.env.OPENCLI_VERBOSE) { 311 // eslint-disable-next-line no-console 312 console.error(`[cdp] getResponseBody failed for ${p.requestId}:`, err instanceof Error ? err.message : err); 313 } 314 }).finally(() => { 315 this._pendingBodyFetches.delete(bodyFetch); 316 }); 317 this._pendingBodyFetches.add(bodyFetch); 318 this._pendingRequests.delete(p.requestId); 319 } 320 }); 321 322 this._networkCapturing = true; 323 } 324 return true; 325 } 326 327 async readNetworkCapture(): Promise<unknown[]> { 328 // Await all in-flight body fetches so entries have responsePreview populated 329 if (this._pendingBodyFetches.size > 0) { 330 await Promise.all([...this._pendingBodyFetches]); 331 } 332 const entries = [...this._networkEntries]; 333 this._networkEntries = []; 334 return entries; 335 } 336 337 async consoleMessages(level: string = 'all'): Promise<Array<{ type: string; text: string; timestamp: number }>> { 338 if (!this._consoleCapturing) { 339 await this.bridge.send('Runtime.enable'); 340 this.bridge.on('Runtime.consoleAPICalled', (params: unknown) => { 341 const p = params as { type: string; args: Array<{ value?: unknown; description?: string }>; timestamp: number }; 342 const text = (p.args || []).map(a => a.value !== undefined ? String(a.value) : (a.description || '')).join(' '); 343 this._consoleMessages.push({ type: p.type, text, timestamp: p.timestamp }); 344 if (this._consoleMessages.length > 500) this._consoleMessages.shift(); 345 }); 346 // Capture uncaught exceptions as error-level messages 347 this.bridge.on('Runtime.exceptionThrown', (params: unknown) => { 348 const p = params as { timestamp: number; exceptionDetails?: { exception?: { description?: string }; text?: string } }; 349 const desc = p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || 'Unknown exception'; 350 this._consoleMessages.push({ type: 'error', text: desc, timestamp: p.timestamp }); 351 if (this._consoleMessages.length > 500) this._consoleMessages.shift(); 352 }); 353 this._consoleCapturing = true; 354 } 355 if (level === 'all') return [...this._consoleMessages]; 356 // 'error' level includes both console.error() and uncaught exceptions 357 if (level === 'error') return this._consoleMessages.filter(m => m.type === 'error' || m.type === 'warning'); 358 return this._consoleMessages.filter(m => m.type === level); 359 } 360 361 async tabs(): Promise<unknown[]> { 362 return []; 363 } 364 365 async selectTab(_target: number | string): Promise<void> { 366 // Not supported in direct CDP mode 367 } 368 } 369 370 function isCookie(value: unknown): value is BrowserCookie { 371 return isRecord(value) 372 && typeof value.name === 'string' 373 && typeof value.value === 'string' 374 && typeof value.domain === 'string'; 375 } 376 377 function matchesCookieDomain(cookieDomain: string, targetDomain: string): boolean { 378 const normalizedCookieDomain = cookieDomain.replace(/^\./, '').toLowerCase(); 379 const normalizedTargetDomain = targetDomain.replace(/^\./, '').toLowerCase(); 380 return normalizedTargetDomain === normalizedCookieDomain 381 || normalizedTargetDomain.endsWith(`.${normalizedCookieDomain}`); 382 } 383 384 function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined { 385 const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET); 386 387 const ranked = targets 388 .map((target, index) => ({ target, index, score: scoreCDPTarget(target, preferredPattern) })) 389 .filter(({ score }) => Number.isFinite(score)) 390 .sort((a, b) => { 391 if (b.score !== a.score) return b.score - a.score; 392 return a.index - b.index; 393 }); 394 395 return ranked[0]?.target; 396 } 397 398 function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number { 399 if (!target.webSocketDebuggerUrl) return Number.NEGATIVE_INFINITY; 400 401 const type = (target.type ?? '').toLowerCase(); 402 const url = (target.url ?? '').toLowerCase(); 403 const title = (target.title ?? '').toLowerCase(); 404 const haystack = `${title} ${url}`; 405 406 if (!haystack.trim() && !type) return Number.NEGATIVE_INFINITY; 407 if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY; 408 if (type === 'background_page' || type === 'service_worker') return Number.NEGATIVE_INFINITY; 409 410 let score = 0; 411 412 if (preferredPattern && preferredPattern.test(haystack)) score += 1000; 413 414 if (type === 'app') score += 120; 415 else if (type === 'webview') score += 100; 416 else if (type === 'page') score += 80; 417 else if (type === 'iframe') score += 20; 418 419 if (url.startsWith('http://localhost') || url.startsWith('https://localhost')) score += 90; 420 if (url.startsWith('file://')) score += 60; 421 if (url.startsWith('http://127.0.0.1') || url.startsWith('https://127.0.0.1')) score += 50; 422 if (url.startsWith('about:blank')) score -= 120; 423 if (url === '' || url === 'about:blank') score -= 40; 424 425 if (title && title !== 'devtools') score += 25; 426 427 // Boost score for known Electron app names from the registry (builtin + user-defined) 428 const appNames = Object.values(getAllElectronApps()).map(a => (a.displayName ?? a.processName).toLowerCase()); 429 for (const name of appNames) { 430 if (title.includes(name)) { score += 120; break; } 431 } 432 for (const name of appNames) { 433 if (url.includes(name)) { score += 100; break; } 434 } 435 436 return score; 437 } 438 439 function compilePreferredPattern(raw: string | undefined): RegExp | undefined { 440 const value = raw?.trim(); 441 if (!value) return undefined; 442 return new RegExp(escapeRegExp(value.toLowerCase())); 443 } 444 445 function escapeRegExp(value: string): string { 446 return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 447 } 448 449 export const __test__ = { 450 selectCDPTarget, 451 scoreCDPTarget, 452 }; 453 454 function fetchJsonDirect(url: string): Promise<unknown> { 455 return new Promise((resolve, reject) => { 456 const parsed = new URL(url); 457 const request = (parsed.protocol === 'https:' ? httpsRequest : httpRequest)(parsed, (res) => { 458 const statusCode = res.statusCode ?? 0; 459 if (statusCode < 200 || statusCode >= 300) { 460 res.resume(); 461 reject(new Error(`Failed to fetch CDP targets: HTTP ${statusCode}`)); 462 return; 463 } 464 465 const chunks: Buffer[] = []; 466 res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); 467 res.on('end', () => { 468 try { 469 resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); 470 } catch (error) { 471 reject(error instanceof Error ? error : new Error(String(error))); 472 } 473 }); 474 }); 475 476 request.on('error', reject); 477 request.setTimeout(10_000, () => request.destroy(new Error('Timed out fetching CDP targets'))); 478 request.end(); 479 }); 480 }