/ extension / src / cdp.ts
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  }