cdp.ts
1 /** 2 * CDP execution via chrome.debugger API. 3 * 4 * chrome.debugger only needs the "debugger" permission — no host_permissions. 5 * It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// 6 * tabs (resolveTabId in background.ts filters them). 7 */ 8 9 const attached = new Set<number>(); 10 11 const tabFrameContexts = new Map<number, Map<string, number>>(); 12 13 // Large cap so agents stop hitting silent JSON.parse failures on real API bodies. 14 // See src/browser/cdp.ts CDP_RESPONSE_BODY_CAPTURE_LIMIT for the matching constant 15 // on the direct-CDP path. Keep in sync. 16 const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; 17 const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024; 18 19 type NetworkCaptureEntry = { 20 kind: 'cdp'; 21 url: string; 22 method: string; 23 requestHeaders?: Record<string, string>; 24 requestBodyKind?: string; 25 requestBodyPreview?: string; 26 requestBodyFullSize?: number; 27 requestBodyTruncated?: boolean; 28 responseStatus?: number; 29 responseContentType?: string; 30 responseHeaders?: Record<string, string>; 31 responsePreview?: string; 32 responseBodyFullSize?: number; 33 responseBodyTruncated?: boolean; 34 timestamp: number; 35 }; 36 37 type NetworkCaptureState = { 38 patterns: string[]; 39 entries: NetworkCaptureEntry[]; 40 requestToIndex: Map<string, number>; 41 }; 42 43 const networkCaptures = new Map<number, NetworkCaptureState>(); 44 /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ 45 function isDebuggableUrl(url?: string): boolean { 46 if (!url) return true; // empty/undefined = tab still loading, allow it 47 return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:'); 48 } 49 50 export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise<void> { 51 // Verify the tab URL is debuggable before attempting attach 52 try { 53 const tab = await chrome.tabs.get(tabId); 54 if (!isDebuggableUrl(tab.url)) { 55 // Invalidate cache if previously attached 56 attached.delete(tabId); 57 throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`); 58 } 59 } catch (e) { 60 // Re-throw our own error, catch only chrome.tabs.get failures 61 if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e; 62 attached.delete(tabId); 63 throw new Error(`Tab ${tabId} no longer exists`); 64 } 65 66 if (attached.has(tabId)) { 67 // Verify the debugger is still actually attached by sending a harmless command 68 try { 69 await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { 70 expression: '1', returnByValue: true, 71 }); 72 return; // Still attached and working 73 } catch { 74 // Stale cache entry — need to re-attach 75 attached.delete(tabId); 76 } 77 } 78 79 // Retry attach up to 3 times — other extensions (1Password, Playwright MCP Bridge) 80 // can temporarily interfere with chrome.debugger. A short delay usually resolves it. 81 // Normal commands: 2 retries, 500ms delay (fast fail for non-browser use) 82 // Browser commands: 5 retries, 1500ms delay (aggressive, tolerates extension interference) 83 const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; 84 const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; 85 let lastError = ''; 86 87 for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { 88 try { 89 // Force detach first to clear any stale state from other extensions 90 try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ } 91 await chrome.debugger.attach({ tabId }, '1.3'); 92 lastError = ''; 93 break; // Success 94 } catch (e: unknown) { 95 lastError = e instanceof Error ? e.message : String(e); 96 if (attempt < MAX_ATTACH_RETRIES) { 97 console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); 98 await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); 99 // Re-verify tab URL before retrying (it may have changed) 100 try { 101 const tab = await chrome.tabs.get(tabId); 102 if (!isDebuggableUrl(tab.url)) { 103 lastError = `Tab URL changed to ${tab.url} during retry`; 104 break; // Don't retry if URL became un-debuggable 105 } 106 } catch { 107 // Tab is gone — don't fail early here. 108 // Later retry layers can re-resolve a fresh automation tab/window. 109 lastError = `Tab ${tabId} no longer exists`; 110 // Don't break; fall through to retry 111 } 112 } 113 } 114 } 115 116 if (lastError) { 117 // Log detailed diagnostics for debugging extension conflicts 118 let finalUrl = 'unknown'; 119 let finalWindowId = 'unknown'; 120 try { 121 const tab = await chrome.tabs.get(tabId); 122 finalUrl = tab.url ?? 'undefined'; 123 finalWindowId = String(tab.windowId); 124 } catch { /* tab gone */ } 125 console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); 126 127 const hint = lastError.includes('chrome-extension://') 128 ? '. Tip: another Chrome extension may be interfering — try disabling other extensions' 129 : ''; 130 throw new Error(`attach failed: ${lastError}${hint}`); 131 } 132 attached.add(tabId); 133 134 try { 135 await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); 136 } catch { 137 // Some pages may not need explicit enable 138 } 139 } 140 141 export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise<unknown> { 142 // Retry the entire evaluate (attach + command). 143 // Normal: 2 retries. Browser: 3 retries (tolerates extension interference). 144 const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; 145 for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { 146 try { 147 await ensureAttached(tabId, aggressiveRetry); 148 149 const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { 150 expression, 151 returnByValue: true, 152 awaitPromise: true, 153 }) as { 154 result?: { type: string; value?: unknown; description?: string; subtype?: string }; 155 exceptionDetails?: { exception?: { description?: string }; text?: string }; 156 }; 157 158 if (result.exceptionDetails) { 159 const errMsg = result.exceptionDetails.exception?.description 160 || result.exceptionDetails.text 161 || 'Eval error'; 162 throw new Error(errMsg); 163 } 164 165 return result.result?.value; 166 } catch (e) { 167 const msg = e instanceof Error ? e.message : String(e); 168 // Only retry on attach/debugger errors, not on JS eval errors 169 const isNavigateError = msg.includes('Inspected target navigated') || msg.includes('Target closed'); 170 const isAttachError = isNavigateError || msg.includes('attach failed') || msg.includes('Debugger is not attached') 171 || msg.includes('chrome-extension://'); 172 if (isAttachError && attempt < MAX_EVAL_RETRIES) { 173 attached.delete(tabId); // Force re-attach on next attempt 174 // SPA navigations recover quickly; debugger detach needs longer 175 const retryMs = isNavigateError ? 200 : 500; 176 await new Promise(resolve => setTimeout(resolve, retryMs)); 177 continue; 178 } 179 throw e; 180 } 181 } 182 throw new Error('evaluate: max retries exhausted'); 183 } 184 185 export const evaluateAsync = evaluate; 186 187 /** 188 * Capture a screenshot via CDP Page.captureScreenshot. 189 * Returns base64-encoded image data. 190 */ 191 export async function screenshot( 192 tabId: number, 193 options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean } = {}, 194 ): Promise<string> { 195 await ensureAttached(tabId); 196 197 const format = options.format ?? 'png'; 198 199 // For full-page screenshots, get the full page dimensions first 200 if (options.fullPage) { 201 // Get full page metrics 202 const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as { 203 contentSize?: { width: number; height: number }; 204 cssContentSize?: { width: number; height: number }; 205 }; 206 const size = metrics.cssContentSize || metrics.contentSize; 207 if (size) { 208 // Set device metrics to full page size 209 await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', { 210 mobile: false, 211 width: Math.ceil(size.width), 212 height: Math.ceil(size.height), 213 deviceScaleFactor: 1, 214 }); 215 } 216 } 217 218 try { 219 const params: Record<string, unknown> = { format }; 220 if (format === 'jpeg' && options.quality !== undefined) { 221 params.quality = Math.max(0, Math.min(100, options.quality)); 222 } 223 224 const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', params) as { 225 data: string; // base64-encoded 226 }; 227 228 return result.data; 229 } finally { 230 // Reset device metrics if we changed them for full-page 231 if (options.fullPage) { 232 await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {}); 233 } 234 } 235 } 236 237 /** 238 * Set local file paths on a file input element via CDP DOM.setFileInputFiles. 239 * This bypasses the need to send large base64 payloads through the message channel — 240 * Chrome reads the files directly from the local filesystem. 241 * 242 * @param tabId - Target tab ID 243 * @param files - Array of absolute local file paths 244 * @param selector - CSS selector to find the file input (optional, defaults to first file input) 245 */ 246 export async function setFileInputFiles( 247 tabId: number, 248 files: string[], 249 selector?: string, 250 ): Promise<void> { 251 await ensureAttached(tabId); 252 253 // Enable DOM domain (required for DOM.querySelector and DOM.setFileInputFiles) 254 await chrome.debugger.sendCommand({ tabId }, 'DOM.enable'); 255 256 // Get the document root 257 const doc = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument') as { 258 root: { nodeId: number }; 259 }; 260 261 // Find the file input element 262 const query = selector || 'input[type="file"]'; 263 const result = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', { 264 nodeId: doc.root.nodeId, 265 selector: query, 266 }) as { nodeId: number }; 267 268 if (!result.nodeId) { 269 throw new Error(`No element found matching selector: ${query}`); 270 } 271 272 // Set files directly via CDP — Chrome reads from local filesystem 273 await chrome.debugger.sendCommand({ tabId }, 'DOM.setFileInputFiles', { 274 files, 275 nodeId: result.nodeId, 276 }); 277 } 278 279 export async function insertText( 280 tabId: number, 281 text: string, 282 ): Promise<void> { 283 await ensureAttached(tabId); 284 await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text }); 285 } 286 287 export function registerFrameTracking(): void { 288 chrome.debugger.onEvent.addListener((source, method, params: any) => { 289 const tabId = source.tabId; 290 if (!tabId) return; 291 292 if (method === 'Runtime.executionContextCreated') { 293 const context = params.context; 294 if (!context?.auxData?.frameId || context.auxData.isDefault !== true) return; 295 const frameId = context.auxData.frameId as string; 296 if (!tabFrameContexts.has(tabId)) { 297 tabFrameContexts.set(tabId, new Map()); 298 } 299 tabFrameContexts.get(tabId)!.set(frameId, context.id); 300 } 301 302 if (method === 'Runtime.executionContextDestroyed') { 303 const ctxId = params.executionContextId; 304 const contexts = tabFrameContexts.get(tabId); 305 if (contexts) { 306 for (const [fid, cid] of contexts) { 307 if (cid === ctxId) { contexts.delete(fid); break; } 308 } 309 } 310 } 311 312 if (method === 'Runtime.executionContextsCleared') { 313 tabFrameContexts.delete(tabId); 314 } 315 }); 316 317 chrome.tabs.onRemoved.addListener((tabId) => { 318 tabFrameContexts.delete(tabId); 319 }); 320 } 321 322 export async function getFrameTree(tabId: number): Promise<any> { 323 await ensureAttached(tabId); 324 return chrome.debugger.sendCommand({ tabId }, 'Page.getFrameTree'); 325 } 326 327 export async function evaluateInFrame( 328 tabId: number, 329 expression: string, 330 frameId: string, 331 aggressiveRetry: boolean = false, 332 ): Promise<unknown> { 333 await ensureAttached(tabId, aggressiveRetry); 334 335 await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable').catch(() => {}); 336 337 const contexts = tabFrameContexts.get(tabId); 338 const contextId = contexts?.get(frameId); 339 340 if (contextId === undefined) { 341 throw new Error(`No execution context found for frame ${frameId}. The frame may not be loaded yet.`); 342 } 343 344 const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { 345 expression, 346 contextId, 347 returnByValue: true, 348 awaitPromise: true, 349 }) as { 350 result?: { type: string; value?: unknown; description?: string; subtype?: string }; 351 exceptionDetails?: { exception?: { description?: string }; text?: string }; 352 }; 353 354 if (result.exceptionDetails) { 355 const errMsg = result.exceptionDetails.exception?.description 356 || result.exceptionDetails.text 357 || 'Eval error'; 358 throw new Error(errMsg); 359 } 360 361 return result.result?.value; 362 } 363 364 function normalizeCapturePatterns(pattern?: string): string[] { 365 return String(pattern || '') 366 .split('|') 367 .map((part) => part.trim()) 368 .filter(Boolean); 369 } 370 371 function shouldCaptureUrl(url: string | undefined, patterns: string[]): boolean { 372 if (!url) return false; 373 if (!patterns.length) return true; 374 return patterns.some((pattern) => url.includes(pattern)); 375 } 376 377 function normalizeHeaders(headers: unknown): Record<string, string> { 378 if (!headers || typeof headers !== 'object') return {}; 379 const out: Record<string, string> = {}; 380 for (const [key, value] of Object.entries(headers as Record<string, unknown>)) { 381 out[String(key)] = String(value); 382 } 383 return out; 384 } 385 386 function getOrCreateNetworkCaptureEntry(tabId: number, requestId: string, fallback?: { 387 url?: string; 388 method?: string; 389 requestHeaders?: Record<string, string>; 390 }): NetworkCaptureEntry | null { 391 const state = networkCaptures.get(tabId); 392 if (!state) return null; 393 const existingIndex = state.requestToIndex.get(requestId); 394 if (existingIndex !== undefined) { 395 return state.entries[existingIndex] || null; 396 } 397 const url = fallback?.url || ''; 398 if (!shouldCaptureUrl(url, state.patterns)) return null; 399 const entry: NetworkCaptureEntry = { 400 kind: 'cdp', 401 url, 402 method: fallback?.method || 'GET', 403 requestHeaders: fallback?.requestHeaders || {}, 404 timestamp: Date.now(), 405 }; 406 state.entries.push(entry); 407 state.requestToIndex.set(requestId, state.entries.length - 1); 408 return entry; 409 } 410 411 export async function startNetworkCapture( 412 tabId: number, 413 pattern?: string, 414 ): Promise<void> { 415 await ensureAttached(tabId); 416 await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); 417 networkCaptures.set(tabId, { 418 patterns: normalizeCapturePatterns(pattern), 419 entries: [], 420 requestToIndex: new Map(), 421 }); 422 } 423 424 export async function readNetworkCapture(tabId: number): Promise<NetworkCaptureEntry[]> { 425 const state = networkCaptures.get(tabId); 426 if (!state) return []; 427 const entries = state.entries.slice(); 428 state.entries = []; 429 state.requestToIndex.clear(); 430 return entries; 431 } 432 433 export function hasActiveNetworkCapture(tabId: number): boolean { 434 return networkCaptures.has(tabId); 435 } 436 437 export async function detach(tabId: number): Promise<void> { 438 if (!attached.has(tabId)) return; 439 attached.delete(tabId); 440 networkCaptures.delete(tabId); 441 tabFrameContexts.delete(tabId); 442 try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ } 443 } 444 445 export function registerListeners(): void { 446 chrome.tabs.onRemoved.addListener((tabId) => { 447 attached.delete(tabId); 448 networkCaptures.delete(tabId); 449 tabFrameContexts.delete(tabId); 450 }); 451 chrome.debugger.onDetach.addListener((source) => { 452 if (source.tabId) { 453 attached.delete(source.tabId); 454 networkCaptures.delete(source.tabId); 455 tabFrameContexts.delete(source.tabId); 456 } 457 }); 458 // Invalidate attached cache when tab URL changes to non-debuggable 459 chrome.tabs.onUpdated.addListener(async (tabId, info) => { 460 if (info.url && !isDebuggableUrl(info.url)) { 461 await detach(tabId); 462 } 463 }); 464 chrome.debugger.onEvent.addListener(async (source, method, params) => { 465 const tabId = source.tabId; 466 if (!tabId) return; 467 const state = networkCaptures.get(tabId); 468 if (!state) return; 469 470 if (method === 'Network.requestWillBeSent') { 471 const requestId = String(params?.requestId || ''); 472 const request = params?.request as { 473 url?: string; 474 method?: string; 475 headers?: Record<string, unknown>; 476 postData?: string; 477 hasPostData?: boolean; 478 } | undefined; 479 const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { 480 url: request?.url, 481 method: request?.method, 482 requestHeaders: normalizeHeaders(request?.headers), 483 }); 484 if (!entry) return; 485 entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty'; 486 { 487 const raw = String(request?.postData || ''); 488 const fullSize = raw.length; 489 const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; 490 entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; 491 entry.requestBodyFullSize = fullSize; 492 entry.requestBodyTruncated = truncated; 493 } 494 try { 495 const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string }; 496 if (postData?.postData) { 497 const raw = postData.postData; 498 const fullSize = raw.length; 499 const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; 500 entry.requestBodyKind = 'string'; 501 entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; 502 entry.requestBodyFullSize = fullSize; 503 entry.requestBodyTruncated = truncated; 504 } 505 } catch { 506 // Optional; some requests do not expose postData. 507 } 508 return; 509 } 510 511 if (method === 'Network.responseReceived') { 512 const requestId = String(params?.requestId || ''); 513 const response = params?.response as { 514 url?: string; 515 mimeType?: string; 516 status?: number; 517 headers?: Record<string, unknown>; 518 } | undefined; 519 const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { 520 url: response?.url, 521 }); 522 if (!entry) return; 523 entry.responseStatus = response?.status; 524 entry.responseContentType = response?.mimeType || ''; 525 entry.responseHeaders = normalizeHeaders(response?.headers); 526 return; 527 } 528 529 if (method === 'Network.loadingFinished') { 530 const requestId = String(params?.requestId || ''); 531 const stateEntryIndex = state.requestToIndex.get(requestId); 532 if (stateEntryIndex === undefined) return; 533 const entry = state.entries[stateEntryIndex]; 534 if (!entry) return; 535 try { 536 const body = await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId }) as { 537 body?: string; 538 base64Encoded?: boolean; 539 }; 540 if (typeof body?.body === 'string') { 541 const fullSize = body.body.length; 542 const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT; 543 const stored = truncated ? body.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : body.body; 544 entry.responsePreview = body.base64Encoded ? `base64:${stored}` : stored; 545 entry.responseBodyFullSize = fullSize; 546 entry.responseBodyTruncated = truncated; 547 } 548 } catch { 549 // Optional; bodies are unavailable for some requests (e.g. uploads). 550 } 551 } 552 }); 553 }