From c3f26cf7b5e7132349c410906dd760a52d12aca7 Mon Sep 17 00:00:00 2001 From: Nicolas Gomes Ferreira Dos Santos Date: Thu, 23 Apr 2026 20:52:29 -0700 Subject: [PATCH] feat(browse): add extractCookiesViaCdpPipe using CDP pipe transport Pipe-transport equivalent of extractCookiesViaCdp. Uses Target.getTargets + Target.attachToTarget(flatten: true) to acquire a page session over a single CDP pipe connection, then Network.getAllCookies. Still not wired into importCookiesViaCdp; next commit swaps the TCP path over. Refs #1136. --- browse/src/cookie-import-browser.ts | 66 +++++++++++++ browse/test/cookie-import-browser.test.ts | 110 +++++++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index f1e374830..acd128ba0 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -1151,6 +1151,72 @@ export class CdpPipeTransport { } } +// Minimal transport surface needed by extractCookiesViaCdpPipe. Lets tests +// pass a scripted mock without constructing a real CdpPipeTransport. +export interface CdpCallable { + send(method: string, params?: object, sessionId?: string): Promise; +} + +/** + * Extract cookies over a CDP-over-pipe transport. The transport must already + * be wired to a running Chrome process with --remote-debugging-pipe. + * + * Flow: + * 1. Target.getTargets — find a page target + * 2. Target.attachToTarget — flatten session, get sessionId + * 3. Network.enable — on the page session + * 4. Network.getAllCookies — returns every cookie the browser has + * 5. Filter + map to PlaywrightCookie[] + */ +export async function extractCookiesViaCdpPipe( + transport: CdpCallable, + domains: string[], +): Promise { + if (domains.length === 0) return []; + + const targets = await transport.send('Target.getTargets'); + const pageTarget = (targets.targetInfos as Array<{ targetId: string; type: string }>) + .find(t => t.type === 'page'); + if (!pageTarget) { + throw new CookieImportError( + 'No page target found in Chrome for CDP cookie extraction', + 'cdp_error', + ); + } + + const attached = await transport.send('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true, + }); + const sessionId = attached.sessionId as string; + + await transport.send('Network.enable', undefined, sessionId); + const cookiesResp = await transport.send('Network.getAllCookies', undefined, sessionId); + + // Normalize domain matching: `.example.com` should match `example.com` and vice versa. + const domainSet = new Set(); + for (const d of domains) { + domainSet.add(d); + domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d); + } + + const result: PlaywrightCookie[] = []; + for (const c of cookiesResp.cookies as CdpCookie[]) { + if (!domainSet.has(c.domain)) continue; + result.push({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path || '/', + expires: c.expires === -1 ? -1 : c.expires, + secure: c.secure, + httpOnly: c.httpOnly, + sameSite: cdpSameSite(c.sameSite), + }); + } + return result; +} + /** * Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies. * Quick check — reads a small sample, no decryption attempted. diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts index dfe0552f0..bf3665fb8 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -20,7 +20,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { PassThrough } from 'node:stream'; -import { CdpPipeTransport } from '../src/cookie-import-browser'; +import { CdpPipeTransport, extractCookiesViaCdpPipe } from '../src/cookie-import-browser'; // ─── Test Constants ───────────────────────────────────────────── @@ -677,3 +677,111 @@ describe('CdpPipeTransport', () => { t.close(); }); }); + +// A mock transport that replays scripted responses per-method. +// The test writes scripts keyed by method name; send() returns the next +// scripted response for that method. +class ScriptedTransport { + private scripts: Record = {}; + sent: Array<{ method: string; params?: unknown; sessionId?: string }> = []; + + script(method: string, ...responses: unknown[]) { + this.scripts[method] = (this.scripts[method] ?? []).concat(responses); + } + + async send(method: string, params?: object, sessionId?: string): Promise { + this.sent.push({ method, params, sessionId }); + const queue = this.scripts[method]; + if (!queue || queue.length === 0) { + throw new Error(`No scripted response for ${method}`); + } + const next = queue.shift(); + if (next instanceof Error) throw next; + return next; + } +} + +describe('extractCookiesViaCdpPipe', () => { + test('happy path: attaches to first page target, requests cookies, filters by domain', async () => { + const transport = new ScriptedTransport(); + transport.script('Target.getTargets', { + targetInfos: [ + { targetId: 'BROWSER1', type: 'browser' }, + { targetId: 'PAGE1', type: 'page', url: 'about:blank' }, + ], + }); + transport.script('Target.attachToTarget', { sessionId: 'S1' }); + transport.script('Network.enable', {}); + transport.script('Network.getAllCookies', { + cookies: [ + { name: 'sid', value: 'abc', domain: 'example.com', path: '/', expires: 1800000000, size: 4, httpOnly: true, secure: true, session: false, sameSite: 'Lax' }, + { name: 'other', value: 'xyz', domain: 'other.com', path: '/', expires: -1, size: 3, httpOnly: false, secure: false, session: true, sameSite: 'None' }, + ], + }); + + const cookies = await extractCookiesViaCdpPipe(transport as any, ['example.com']); + expect(cookies).toHaveLength(1); + expect(cookies[0]).toEqual({ + name: 'sid', + value: 'abc', + domain: 'example.com', + path: '/', + expires: 1800000000, + secure: true, + httpOnly: true, + sameSite: 'Lax', + }); + + expect(transport.sent.map(s => s.method)).toEqual([ + 'Target.getTargets', + 'Target.attachToTarget', + 'Network.enable', + 'Network.getAllCookies', + ]); + + expect(transport.sent[1].params).toEqual({ targetId: 'PAGE1', flatten: true }); + + expect(transport.sent[2].sessionId).toBe('S1'); + expect(transport.sent[3].sessionId).toBe('S1'); + }); + + test('matches cookies with leading-dot domain normalization', async () => { + const transport = new ScriptedTransport(); + transport.script('Target.getTargets', { targetInfos: [{ targetId: 'P', type: 'page' }] }); + transport.script('Target.attachToTarget', { sessionId: 'S1' }); + transport.script('Network.enable', {}); + transport.script('Network.getAllCookies', { + cookies: [ + { name: 'a', value: '1', domain: '.example.com', path: '/', expires: -1, size: 1, httpOnly: false, secure: false, session: true, sameSite: 'Lax' }, + ], + }); + + const cookies = await extractCookiesViaCdpPipe(transport as any, ['example.com']); + expect(cookies).toHaveLength(1); + expect(cookies[0].domain).toBe('.example.com'); + }); + + test('throws cdp_error if no page target exists', async () => { + const transport = new ScriptedTransport(); + transport.script('Target.getTargets', { + targetInfos: [{ targetId: 'BROWSER1', type: 'browser' }], + }); + + let caught: any = null; + try { + await extractCookiesViaCdpPipe(transport as any, ['example.com']); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught!.code).toBe('cdp_error'); + expect(caught!.message.toLowerCase()).toContain('page target'); + }); + + test('empty domains list short-circuits to empty result', async () => { + const transport = new ScriptedTransport(); + const cookies = await extractCookiesViaCdpPipe(transport as any, []); + expect(cookies).toEqual([]); + expect(transport.sent).toHaveLength(0); + }); +});