diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 66328432a..f1e374830 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -40,6 +40,7 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import type { Writable as NodeWritable, Readable as NodeReadable } from 'node:stream'; import { TEMP_DIR } from './platform'; // ─── Types ────────────────────────────────────────────────────── @@ -1020,6 +1021,136 @@ function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' { } } +// ─── Internal: CDP-over-pipe transport ───────────────────────── +// Chrome's --remote-debugging-pipe exposes CDP over anonymous stdio pipes +// rather than a TCP socket. Framing is simple: UTF-8 JSON payloads delimited +// by single NUL bytes (0x00), in both directions. No length prefix, no +// WebSocket envelope. +// +// The transport accepts Node-style streams (Writable + Readable) so it can +// be unit-tested with PassThrough without spawning a real subprocess. In +// production, the streams come from `child.stdio[3]` (net.Socket, writable — +// parent→Chrome) and `child.stdio[4]` (net.Socket, readable — Chrome→parent) +// of a `node:child_process.spawn` child. +// +// Why node:child_process and not Bun.spawn: Bun.spawn exposes fds ≥ 3 as +// raw numeric file descriptors rather than stream objects, and the Node +// Writable.toWeb / Readable.toWeb bridges hang in Bun. node:child_process +// gives us net.Socket endpoints that Just Work. + +type CdpPending = { + resolve: (value: any) => void; + reject: (err: Error) => void; +}; + +export class CdpPipeTransport { + private nextId = 1; + private pending = new Map(); + private closed = false; + private writeStream: NodeWritable; + private readStream: NodeReadable; + private readBuffer = new Uint8Array(0); + private decoder = new TextDecoder(); + + constructor(writeStream: NodeWritable, readStream: NodeReadable) { + this.writeStream = writeStream; + this.readStream = readStream; + + this.readStream.on('data', (chunk: Buffer | string) => { + const bytes = typeof chunk === 'string' + ? new TextEncoder().encode(chunk) + : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + this.ingest(bytes); + }); + this.readStream.on('end', () => { + if (!this.closed) { + this.abortAll(new CookieImportError('CDP read stream ended unexpectedly', 'cdp_error')); + } + }); + this.readStream.on('error', (err: Error) => this.abortAll(err)); + this.writeStream.on('error', (err: Error) => this.abortAll(err)); + } + + send(method: string, params?: object, sessionId?: string): Promise { + if (this.closed) { + return Promise.reject(new CookieImportError('CDP transport closed', 'cdp_error')); + } + const id = this.nextId++; + const frame: Record = { id, method }; + if (params !== undefined) frame.params = params; + if (sessionId !== undefined) frame.sessionId = sessionId; + const promise = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + // Write is fire-and-forget; errors bubble via the 'error' listener above. + this.writeStream.write(JSON.stringify(frame) + '\x00'); + return promise; + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.abortAll(new CookieImportError('CDP transport closed', 'cdp_error')); + try { this.writeStream.end(); } catch {} + } + + private ingest(chunk: Uint8Array): void { + // Append to accumulated buffer. + const combined = new Uint8Array(this.readBuffer.length + chunk.length); + combined.set(this.readBuffer, 0); + combined.set(chunk, this.readBuffer.length); + this.readBuffer = combined; + + // Split on NUL; last piece (no trailing NUL) becomes the new buffer. + let start = 0; + for (let i = 0; i < this.readBuffer.length; i++) { + if (this.readBuffer[i] === 0x00) { + const frame = this.readBuffer.slice(start, i); + start = i + 1; + this.handleFrame(this.decoder.decode(frame)); + } + } + this.readBuffer = this.readBuffer.slice(start); + } + + private handleFrame(text: string): void { + if (text.length === 0) return; + let msg: any; + try { + msg = JSON.parse(text); + } catch { + // Malformed frame — ignore. Real CDP does not send malformed frames; + // if we see one, it's a bug, but crashing the transport punishes the + // caller for Chrome's bug. + return; + } + + // Responses have an id. Events (no id) are ignored — we don't subscribe. + if (typeof msg.id !== 'number') return; + + const pending = this.pending.get(msg.id); + if (!pending) return; + this.pending.delete(msg.id); + + if (msg.error) { + pending.reject(new CookieImportError( + `CDP error: ${msg.error.message || 'unknown'}`, + 'cdp_error', + )); + } else { + pending.resolve(msg.result ?? {}); + } + } + + private abortAll(err: Error): void { + const wrapped = err instanceof CookieImportError + ? err + : new CookieImportError(`CDP transport error: ${err.message}`, 'cdp_error'); + for (const pending of this.pending.values()) pending.reject(wrapped); + this.pending.clear(); + } +} + /** * 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 5e9a5b441..dfe0552f0 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -19,6 +19,8 @@ import * as crypto from 'crypto'; 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'; // ─── Test Constants ───────────────────────────────────────────── @@ -517,3 +519,161 @@ describe('Cookie Import Browser', () => { }); }); }); + +// ─── CdpPipeTransport ──────────────────────────────────────────── + +// Helper: create a controllable pair of PassThrough streams mimicking Chrome's +// pipe ends. `writeToChild` is the stream the transport writes TO (Chrome +// would read). `readFromChild` is the stream the transport reads FROM (Chrome +// would write). PassThrough is structurally equivalent to the net.Socket ends +// that node:child_process.spawn returns at stdio[3]/[4] for this purpose. +function makeMockPipes() { + const writeToChild = new PassThrough(); + const readFromChild = new PassThrough(); + const writtenChunks: Buffer[] = []; + writeToChild.on('data', (chunk: Buffer) => writtenChunks.push(chunk)); + + return { + writeToChild, + readFromChild, + getWritten: () => Buffer.concat(writtenChunks).toString('utf-8'), + pushFromChild: (s: string) => readFromChild.write(s), + closeFromChild: () => readFromChild.end(), + errorFromChild: (err: Error) => readFromChild.destroy(err), + }; +} + +// Flush pending I/O ticks so writes reach the PassThrough 'data' listener. +const flush = async () => { + for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r)); +}; + +describe('CdpPipeTransport', () => { + test('send writes a NUL-terminated JSON frame with monotonic id', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + // Attach catch handlers — we never await these, but close() will reject them. + t.send('Network.enable').catch(() => {}); + t.send('Network.getAllCookies').catch(() => {}); + await flush(); + + const written = pipes.getWritten(); + expect(written).toBe( + '{"id":1,"method":"Network.enable"}\x00' + + '{"id":2,"method":"Network.getAllCookies"}\x00' + ); + + t.close(); + }); + + test('send resolves when matching id arrives as a single frame', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.enable'); + pipes.pushFromChild('{"id":1,"result":{}}\x00'); + const result = await pending; + expect(result).toEqual({}); + + t.close(); + }); + + test('send resolves when response arrives split across two reads at the NUL boundary', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.getAllCookies'); + pipes.pushFromChild('{"id":1,"result":{"coo'); + pipes.pushFromChild('kies":[]}}\x00'); + const result = await pending; + expect(result).toEqual({ cookies: [] }); + + t.close(); + }); + + test('ingest handles multiple frames arriving in one chunk', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const p1 = t.send('Network.enable'); + const p2 = t.send('Network.getAllCookies'); + pipes.pushFromChild('{"id":1,"result":{}}\x00{"id":2,"result":{"cookies":[{"name":"x"}]}}\x00'); + + expect(await p1).toEqual({}); + expect(await p2).toEqual({ cookies: [{ name: 'x' }] }); + + t.close(); + }); + + test('error frame rejects the matching pending promise with cdp_error', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.getAllCookies'); + pipes.pushFromChild('{"id":1,"error":{"code":-32000,"message":"nope"}}\x00'); + + let caught: any = null; + try { await pending; } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught!.code).toBe('cdp_error'); + expect(caught!.message).toContain('nope'); + + t.close(); + }); + + test('close rejects pending promises with cdp_error', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.enable'); + t.close(); + + let caught: any = null; + try { await pending; } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught!.code).toBe('cdp_error'); + expect(caught!.message.toLowerCase()).toContain('closed'); + }); + + test('unexpected read-stream end rejects pending with cdp_error', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.enable'); + pipes.closeFromChild(); + + let caught: any = null; + try { await pending; } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught!.code).toBe('cdp_error'); + }); + + test('send with explicit sessionId includes it in the frame', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + t.send('Network.enable', undefined, 'SESSION123').catch(() => {}); + await flush(); + + const written = pipes.getWritten(); + expect(written).toBe('{"id":1,"method":"Network.enable","sessionId":"SESSION123"}\x00'); + + t.close(); + }); + + test('send with params serializes them into the frame', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + t.send('Target.attachToTarget', { targetId: 'T1', flatten: true }).catch(() => {}); + await flush(); + + const written = pipes.getWritten(); + expect(written).toBe( + '{"id":1,"method":"Target.attachToTarget","params":{"targetId":"T1","flatten":true}}\x00' + ); + + t.close(); + }); +});