diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 480f32ae..1d168fce 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -36,7 +36,7 @@ vi.mock('ws', () => ({ WebSocket: MockWebSocket, })); -import { CDPBridge } from './cdp.js'; +import { CDPBridge, __test__ } from './cdp.js'; describe('CDPBridge cookies', () => { beforeEach(() => { @@ -64,3 +64,30 @@ describe('CDPBridge cookies', () => { ]); }); }); + +describe('CDP target selection', () => { + it('selects a real page target when attaching through a browser-level websocket', () => { + const target = __test__.selectCDPAttachTarget([ + { + targetId: 'worker-1', + type: 'service_worker', + title: 'Service Worker chrome-extension://abc/background.js', + url: 'chrome-extension://abc/background.js', + }, + { + targetId: 'page-1', + type: 'page', + title: 'Cloudflare Dashboard', + url: 'https://dash.cloudflare.com', + }, + { + targetId: 'iframe-1', + type: 'iframe', + title: 'Cloudflare Turnstile', + url: 'https://challenges.cloudflare.com/cdn-cgi/challenge-platform/...', + }, + ]); + + expect(target?.targetId).toBe('page-1'); + }); +}); diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 14b1054f..a10342a6 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -12,6 +12,7 @@ import { WebSocket, type RawData } from 'ws'; import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js'; +import { discoverLocalChromeCdpEndpoint, resolveCdpEndpoint } from './discover.js'; import { wrapForEval } from './utils.js'; import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js'; import { generateStealthJs } from './stealth.js'; @@ -28,9 +29,11 @@ import { import { isRecord, saveBase64ToFile } from '../utils.js'; export interface CDPTarget { + targetId?: string; type?: string; url?: string; title?: string; + attached?: boolean; webSocketDebuggerUrl?: string; } @@ -49,6 +52,8 @@ const CDP_SEND_TIMEOUT = 30_000; export class CDPBridge { private _ws: WebSocket | null = null; + private _sessionId: string | null = null; + private _attachedTargetId: string | null = null; private _idCounter = 0; private _pending = new Map void; reject: (err: Error) => void; timer: ReturnType }>(); private _eventListeners = new Map void>>(); @@ -56,31 +61,31 @@ export class CDPBridge { async connect(opts?: { timeout?: number; workspace?: string }): Promise { if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.'); - const endpoint = process.env.OPENCLI_CDP_ENDPOINT; + const endpoint = resolveCdpEndpoint().endpoint ?? process.env.OPENCLI_CDP_ENDPOINT; if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set'); let wsUrl = endpoint; if (endpoint.startsWith('http')) { - const targets = await fetchJsonDirect(`${endpoint.replace(/\/$/, '')}/json`) as CDPTarget[]; - const target = selectCDPTarget(targets); - if (!target || !target.webSocketDebuggerUrl) { - throw new Error('No inspectable targets found at CDP endpoint'); - } - wsUrl = target.webSocketDebuggerUrl; + wsUrl = await resolveHttpEndpoint(endpoint); } + const browserLevelWs = isBrowserLevelWebSocketUrl(wsUrl); + return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl); const timeoutMs = (opts?.timeout ?? 10) * 1000; const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs); ws.on('open', async () => { - clearTimeout(timeout); this._ws = ws; try { + if (browserLevelWs) { + await this.attachToInspectableTarget(); + } await this.send('Page.enable'); await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() }); } catch {} + clearTimeout(timeout); resolve(new CDPPage(this)); }); @@ -93,6 +98,9 @@ export class CDPBridge { try { const msg = JSON.parse(data.toString()); if (msg.id && this._pending.has(msg.id)) { + if (msg.sessionId && this._sessionId && msg.sessionId !== this._sessionId) { + return; + } const entry = this._pending.get(msg.id)!; clearTimeout(entry.timer); this._pending.delete(msg.id); @@ -103,6 +111,12 @@ export class CDPBridge { } } if (msg.method) { + if (msg.sessionId && this._sessionId && msg.sessionId !== this._sessionId) { + return; + } + if (!msg.sessionId && this._sessionId && msg.method !== 'Target.attachedToTarget' && msg.method !== 'Target.detachedFromTarget') { + return; + } const listeners = this._eventListeners.get(msg.method); if (listeners) { for (const fn of listeners) fn(msg.params); @@ -114,10 +128,19 @@ export class CDPBridge { } async close(): Promise { + if (this._ws && this._ws.readyState === WebSocket.OPEN && this._sessionId) { + try { + await this.send('Target.detachFromTarget', { sessionId: this._sessionId }, 5_000, { root: true }); + } catch { + // Ignore detach errors during shutdown. + } + } if (this._ws) { this._ws.close(); this._ws = null; } + this._sessionId = null; + this._attachedTargetId = null; for (const p of this._pending.values()) { clearTimeout(p.timer); p.reject(new Error('CDP connection closed')); @@ -126,18 +149,27 @@ export class CDPBridge { this._eventListeners.clear(); } - async send(method: string, params: Record = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise { + async send( + method: string, + params: Record = {}, + timeoutMs: number = CDP_SEND_TIMEOUT, + opts: { root?: boolean } = {}, + ): Promise { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { throw new Error('CDP connection is not open'); } const id = ++this._idCounter; + const payload: Record = { id, method, params }; + if (this.shouldSendViaSession(method, opts)) { + payload.sessionId = this._sessionId; + } return new Promise((resolve, reject) => { const timer = setTimeout(() => { this._pending.delete(id); reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`)); }, timeoutMs); this._pending.set(id, { resolve, reject, timer }); - this._ws!.send(JSON.stringify({ id, method, params })); + this._ws!.send(JSON.stringify(payload)); }); } @@ -168,6 +200,42 @@ export class CDPBridge { this.on(event, handler); }); } + + private shouldSendViaSession(method: string, opts: { root?: boolean }): boolean { + if (opts.root || !this._sessionId) return false; + if (method.startsWith('Target.') || method.startsWith('Browser.')) return false; + return true; + } + + private async attachToInspectableTarget(): Promise { + const targetsResult = await this.send('Target.getTargets', {}, CDP_SEND_TIMEOUT, { root: true }) as { targetInfos?: CDPTarget[] }; + let target = selectCDPAttachTarget(targetsResult.targetInfos ?? []); + + if (!target?.targetId) { + const created = await this.send('Target.createTarget', { url: 'about:blank' }, CDP_SEND_TIMEOUT, { root: true }) as { targetId?: string }; + if (!created.targetId) { + throw new Error('No attachable page target found at CDP endpoint'); + } + target = { + targetId: created.targetId, + type: 'page', + title: 'about:blank', + url: 'about:blank', + }; + } + + const attach = await this.send('Target.attachToTarget', { + targetId: target.targetId, + flatten: true, + }, CDP_SEND_TIMEOUT, { root: true }) as { sessionId?: string }; + + if (!attach.sessionId) { + throw new Error('Failed to attach to the selected CDP target'); + } + + this._sessionId = attach.sessionId; + this._attachedTargetId = target.targetId ?? null; + } } class CDPPage implements IPage { @@ -335,22 +403,74 @@ function matchesCookieDomain(cookieDomain: string, targetDomain: string): boolea || normalizedTargetDomain.endsWith(`.${normalizedCookieDomain}`); } +async function resolveHttpEndpoint(endpoint: string): Promise { + const base = endpoint.replace(/\/$/, ''); + + try { + const targets = await fetchJsonDirect(`${base}/json`) as CDPTarget[]; + const target = selectCDPTarget(targets); + if (!target || !target.webSocketDebuggerUrl) { + throw new Error('No inspectable targets found at CDP endpoint'); + } + return target.webSocketDebuggerUrl; + } catch (error) { + if (isLocalHttpEndpoint(endpoint)) { + const localBrowserWs = discoverLocalChromeCdpEndpoint(); + if (localBrowserWs) return localBrowserWs; + } + throw error; + } +} + +function isLocalHttpEndpoint(endpoint: string): boolean { + try { + const url = new URL(endpoint); + return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1'; + } catch { + return false; + } +} + +function isBrowserLevelWebSocketUrl(endpoint: string): boolean { + return /\/devtools\/browser\//.test(endpoint); +} + function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined { const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET); + return rankTargets(targets, preferredPattern, { requireSocketUrl: true }); +} + +function selectCDPAttachTarget(targets: CDPTarget[]): CDPTarget | undefined { + const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET); + const attachable = targets.filter((target) => { + const type = (target.type ?? '').toLowerCase(); + return type === 'app' || type === 'webview' || type === 'page'; + }); + return rankTargets(attachable, preferredPattern, { requireSocketUrl: false }); +} +function rankTargets( + targets: CDPTarget[], + preferredPattern: RegExp | undefined, + opts: { requireSocketUrl: boolean }, +): CDPTarget | undefined { const ranked = targets - .map((target, index) => ({ target, index, score: scoreCDPTarget(target, preferredPattern) })) + .map((target, index) => ({ target, index, score: scoreCDPTarget(target, preferredPattern, opts) })) .filter(({ score }) => Number.isFinite(score)) .sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return a.index - b.index; }); - return ranked[0]?.target; } -function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number { - if (!target.webSocketDebuggerUrl) return Number.NEGATIVE_INFINITY; +function scoreCDPTarget( + target: CDPTarget, + preferredPattern?: RegExp, + opts: { requireSocketUrl: boolean } = { requireSocketUrl: true }, +): number { + if (opts.requireSocketUrl && !target.webSocketDebuggerUrl) return Number.NEGATIVE_INFINITY; + if (!opts.requireSocketUrl && !target.targetId) return Number.NEGATIVE_INFINITY; const type = (target.type ?? '').toLowerCase(); const url = (target.url ?? '').toLowerCase(); @@ -359,6 +479,7 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number { if (!haystack.trim() && !type) return Number.NEGATIVE_INFINITY; if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY; + if (url.startsWith('chrome-extension://')) return Number.NEGATIVE_INFINITY; let score = 0; @@ -405,6 +526,7 @@ function escapeRegExp(value: string): string { export const __test__ = { selectCDPTarget, + selectCDPAttachTarget, scoreCDPTarget, }; diff --git a/src/browser/discover.ts b/src/browser/discover.ts index a73cd959..6855d733 100644 --- a/src/browser/discover.ts +++ b/src/browser/discover.ts @@ -5,11 +5,87 @@ * scanning for @playwright/mcp locations. */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; import { isDaemonRunning } from './daemon-client.js'; export { isDaemonRunning }; +export type ResolvedCdpEndpoint = { + endpoint?: string; + source?: 'env' | 'auto'; + requestedByEnv: boolean; +}; + +function isAutoDiscoveryFlag(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'auto'; +} + +export function discoverLocalChromeCdpEndpoint(): string | undefined { + const candidates: string[] = []; + + if (process.env.CHROME_USER_DATA_DIR) { + candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort')); + } + + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'); + candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort')); + candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort')); + } else if (process.platform === 'darwin') { + candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort')); + candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort')); + } else { + candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort')); + candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort')); + candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort')); + } + + for (const filePath of candidates) { + try { + const content = fs.readFileSync(filePath, 'utf-8').trim(); + const lines = content.split(/\r?\n/); + if (lines.length < 2) continue; + const port = parseInt(lines[0] ?? '', 10); + const browserPath = lines[1]?.trim() ?? ''; + if (port > 0 && browserPath.startsWith('/devtools/browser/')) { + return `ws://127.0.0.1:${port}${browserPath}`; + } + } catch { + // Try the next known Chrome profile location. + } + } + + return undefined; +} + +export function resolveCdpEndpoint(): ResolvedCdpEndpoint { + const envValue = process.env.OPENCLI_CDP_ENDPOINT?.trim(); + const requestedByEnv = !!envValue; + + if (envValue && !isAutoDiscoveryFlag(envValue)) { + return { + endpoint: envValue, + source: 'env', + requestedByEnv, + }; + } + + const discovered = discoverLocalChromeCdpEndpoint(); + if (discovered) { + return { + endpoint: discovered, + source: envValue ? 'env' : 'auto', + requestedByEnv, + }; + } + + return { requestedByEnv }; +} + /** * Check daemon status and return connection info. */ diff --git a/src/browser/index.ts b/src/browser/index.ts index 4ec9b5d5..c54419d6 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -23,5 +23,6 @@ export const __test__ = { appendLimited, withTimeoutMs, selectCDPTarget: cdpTest.selectCDPTarget, + selectCDPAttachTarget: cdpTest.selectCDPAttachTarget, scoreCDPTarget: cdpTest.scoreCDPTarget, }; diff --git a/src/doctor.ts b/src/doctor.ts index 55cdd60b..1f626d38 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -7,8 +7,8 @@ import chalk from 'chalk'; import { DEFAULT_DAEMON_PORT } from './constants.js'; -import { checkDaemonStatus } from './browser/discover.js'; -import { BrowserBridge } from './browser/index.js'; +import { checkDaemonStatus, resolveCdpEndpoint } from './browser/discover.js'; +import { BrowserBridge, CDPBridge } from './browser/index.js'; import { listSessions } from './browser/daemon-client.js'; import { getErrorMessage } from './errors.js'; @@ -28,6 +28,7 @@ export type ConnectivityResult = { export type DoctorReport = { cliVersion?: string; + cdpEndpoint?: string; daemonRunning: boolean; extensionConnected: boolean; connectivity?: ConnectivityResult; @@ -41,7 +42,10 @@ export type DoctorReport = { export async function checkConnectivity(opts?: { timeout?: number }): Promise { const start = Date.now(); try { - const mcp = new BrowserBridge(); + const cdpEndpoint = resolveCdpEndpoint().endpoint; + if (cdpEndpoint) process.env.OPENCLI_CDP_ENDPOINT = cdpEndpoint; + const BrowserFactory = cdpEndpoint ? CDPBridge : BrowserBridge; + const mcp = new BrowserFactory(); const page = await mcp.connect({ timeout: opts?.timeout ?? 8 }); // Try a simple eval to verify end-to-end connectivity await page.evaluate('1 + 1'); @@ -53,6 +57,25 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise { + const cdpEndpoint = resolveCdpEndpoint().endpoint; + if (cdpEndpoint) { + process.env.OPENCLI_CDP_ENDPOINT = cdpEndpoint; + const connectivity = opts.live ? await checkConnectivity() : undefined; + const issues: string[] = []; + if (connectivity && !connectivity.ok) { + issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`); + } + return { + cliVersion: opts.cliVersion, + cdpEndpoint, + daemonRunning: false, + extensionConnected: false, + connectivity, + sessions: undefined, + issues, + }; + } + // Try to auto-start daemon if it's not running, so we show accurate status. let initialStatus = await checkDaemonStatus(); if (!initialStatus.running) { @@ -96,6 +119,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise IBrowserFactory { - return (process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge) as unknown as new () => IBrowserFactory; + const resolved = resolveCdpEndpoint(); + if (resolved.endpoint) { + process.env.OPENCLI_CDP_ENDPOINT = resolved.endpoint; + return CDPBridge as unknown as new () => IBrowserFactory; + } + return BrowserBridge as unknown as new () => IBrowserFactory; } function parseEnvTimeout(envVar: string, fallback: number): number {