Skip to content

Commit 35bf2b4

Browse files
committed
feat(core): DataDome headless bypass, anti-detection patches, CloakBrowser integration
- antiDetection config system (stripCOOP, patchPointerMedia, saveCookieDomains, useCloakBrowser) - CloakBrowser lazy optional dependency for DataDome-protected sites (e.g. Booking.com) - viewport: null globally — removes detectable 1280x720 Playwright default - --blink-settings=primaryPointerType=4 + --window-size globally for all adapters - about:blank startup fix: getStatus() navigates to loginUrl before isLoggedIn check - create-adapter scaffold updated with selector stability guidance Booking.com secure.booking.com now works in headless mode via CloakBrowser.
2 parents 10197c4 + 5cb1508 commit 35bf2b4

9 files changed

Lines changed: 288 additions & 16 deletions

File tree

.cursor/hooks/state/continual-learning-index.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,20 @@
5454
"lastProcessedAt": "2026-03-25T16:00:00.000Z"
5555
},
5656
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/672ffd14-9bc9-4c91-9eb7-9759b40e8893/672ffd14-9bc9-4c91-9eb7-9759b40e8893.jsonl": {
57-
"mtimeMs": 1774474577000,
58-
"lastProcessedAt": "2026-03-25T16:00:00.000Z"
57+
"mtimeMs": 1774522118000,
58+
"lastProcessedAt": "2026-03-26T00:00:00.000Z"
59+
},
60+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/d03e9a64-5187-4bee-88df-279763fa977e/d03e9a64-5187-4bee-88df-279763fa977e.jsonl": {
61+
"mtimeMs": 1774474691000,
62+
"lastProcessedAt": "2026-03-26T00:00:00.000Z"
63+
},
64+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/8511b06d-07c8-42f2-8f30-5b6762c2fdc6/8511b06d-07c8-42f2-8f30-5b6762c2fdc6.jsonl": {
65+
"mtimeMs": 1774523258000,
66+
"lastProcessedAt": "2026-03-26T00:00:00.000Z"
67+
},
68+
"/Users/jzarecki/.cursor/projects/Users-jzarecki-Projects-session-mcp/agent-transcripts/9bec9e6f-a179-401c-90cc-6d64720fbfee/9bec9e6f-a179-401c-90cc-6d64720fbfee.jsonl": {
69+
"mtimeMs": 1774534587000,
70+
"lastProcessedAt": "2026-03-26T00:00:00.000Z"
5971
}
6072
}
6173
}
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"version": 1,
3-
"lastRunAtMs": 1774519514313,
4-
"turnsSinceLastRun": 5,
5-
"lastTranscriptMtimeMs": 1774519513950,
6-
"lastProcessedGenerationId": "492daf12-4802-4a02-a01e-a454c517b849",
3+
"lastRunAtMs": 1774534549568,
4+
"turnsSinceLastRun": 6,
5+
"lastTranscriptMtimeMs": 1774534549508,
6+
"lastProcessedGenerationId": "785251f5-f6dd-46fd-bd02-6b91246cd354",
77
"trialStartedAtMs": null
88
}

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Durable facts and correction patterns for this workspace. Updated by continual-l
3030
- Browser mode switching (`headless` / `watch` / `paused`), screenshot, page state, and navigate are consolidated into a single `browser` MCP tool with an `action` parameter — user explicitly asked to reduce tool count ("too many tools"); do NOT revert to 5 separate management tools
3131
- Management tools bypass the LockManager; regular automation tools go through it
3232
- "Raw" Playwright access means exposing the CDP WebSocket URL (`wsEndpoint()`) of each adapter's browser — external agents (Claude Code, Cursor) attach to the already-authenticated session and write their own Playwright scripts via shell
33-
- The Playwright skill pattern: AI writes a script to `/tmp`, executes it via shell — no `run(code)` MCP tool needed
33+
- The Playwright skill pattern: AI writes a script to `/tmp`, executes it via shell — primary approach for shell-capable clients (Cursor, Claude Code); opt-in `run_script` MCP tool planned for clients without shell access (Claude Desktop)
3434
- MCP resources use `page://${site}/snapshot` (site name dynamic) — user pushed back when the URI appeared to hardcode the adapter name
3535
- Testing utilities (`createTestAdapterServer`, `createTestMcpClient`) live at `@browserkit/core/testing` subpath — a separate harness package was explicitly rejected ("I don't think we need it, it should be in either adapter or in core")
3636
- Real Chrome (`channel: "chrome"`) is required for Google-based adapters — Playwright's bundled Chromium is blocked by Google's login with "This browser or app may not be secure". `isLoggedIn` must NOT navigate during login polling or it redirects the user away from the sign-in page.
@@ -41,6 +41,7 @@ Durable facts and correction patterns for this workspace. Updated by continual-l
4141
- Framework navigates to `adapter.loginUrl` before calling `isLoggedIn()` when browser is at `about:blank` — adapters do NOT need to handle this themselves
4242
- `warm_up_browser()` (visiting google/wiki/github before login) was evaluated from stickerdaniel's code — decided as "nice to have" for first-time login, not adopted yet
4343
- `browserkit login <site>` is blocked by the `CI=1` env var that Cursor sets — must run as `CI="" node packages/core/dist/cli.js login <site>` to open a headed browser from within Cursor terminal
44+
- `browser` tool `snapshot` action is planned — returns incremental aria-snapshot diff, more token-efficient than screenshots; `page-snapshot` MCP resource already exists, the action adds diff support. Inspired by `SawyerHood/dev-browser`.
4445

4546
## Design Process Preferences
4647

browserkit.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,12 @@ export default {
1111
channel: "chrome",
1212
},
1313
"/Users/jzarecki/Projects/session-mcp/packages/adapter-linkedin/dist/index.js": { port: 52744, channel: "chrome" },
14+
"/Users/jzarecki/Projects/browserkit-adapter-booking/dist/index.js": {
15+
port: 52745,
16+
// channel: "chrome" not used — CloakBrowser uses its own Chromium binary
17+
antiDetection: {
18+
useCloakBrowser: true, // CloakBrowser's 33 C++ patches handle everything DataDome checks
19+
},
20+
},
1421
},
1522
};

packages/core/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@
2828
},
2929
"dependencies": {
3030
"@modelcontextprotocol/sdk": "^1.10.2",
31-
"pino": "^9.6.0",
3231
"patchright": "^1.51.1",
32+
"pino": "^9.6.0",
33+
"playwright-core": "^1.58.2",
3334
"zod": "^3.24.2"
3435
},
3536
"devDependencies": {
@@ -46,6 +47,7 @@
4647
"README.md"
4748
],
4849
"optionalDependencies": {
49-
"@browserkit/adapter-hackernews": "workspace:*"
50+
"@browserkit/adapter-hackernews": "workspace:*",
51+
"cloakbrowser": "^0.3.18"
5052
}
5153
}

packages/core/src/adapter-server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export async function createAdapterServer(
5757
debugPort: adapterConfig.debugPort,
5858
deviceEmulation: adapterConfig.deviceEmulation,
5959
channel: adapterConfig.channel,
60+
antiDetection: adapterConfig.antiDetection,
6061
};
6162

6263
let lastCallAt: Date | undefined;

packages/core/src/session-manager.ts

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,25 @@ export class SessionManager {
114114
async closeSite(site: string): Promise<void> {
115115
const entry = this.sessions.get(site);
116116
if (!entry) return;
117+
// Save sticky session cookies (e.g. DataDome's `datadome` cookie) to disk
118+
// before closing, so they can be restored on the next headless launch.
119+
if (entry.config.antiDetection?.saveCookieDomains?.length) {
120+
try {
121+
const allCookies = await entry.context.cookies();
122+
const toSave = allCookies.filter((c) =>
123+
(entry.config.antiDetection?.saveCookieDomains ?? []).some(
124+
(d) => c.domain.endsWith(d.replace(/^\./, ""))
125+
)
126+
);
127+
if (toSave.length > 0) {
128+
const savePath = path.join(this.dataDir, "profiles", site, "saved-session-cookies.json");
129+
fs.writeFileSync(savePath, JSON.stringify(toSave, null, 2), { mode: 0o600 });
130+
log.info({ site, cookieCount: toSave.length }, "saved sticky session cookies");
131+
}
132+
} catch (err) {
133+
log.warn({ site, err }, "failed to save sticky session cookies");
134+
}
135+
}
117136
try { await entry.context.close(); } catch (err) { log.warn({ site, err }, "close error"); }
118137
this.sessions.delete(site);
119138
}
@@ -178,17 +197,125 @@ export class SessionManager {
178197
const antiAutomationArgs = [
179198
"--disable-blink-features=AutomationControlled",
180199
"--disable-infobars",
200+
// Declare a primary pointer (mouse) and available pointer types at the Blink level.
201+
// Without this, headless Chrome reports pointer type = none, which causes CSS
202+
// (pointer: fine) to return false — a key DataDome headless detection signal.
203+
// Type 4 = kFine (mouse), type 1 = kNone, type 2 = kCoarse (touch).
204+
"--blink-settings=primaryPointerType=4,availablePointerTypes=4,primaryHoverType=2,availableHoverTypes=2",
205+
// Set a realistic window size so screen.width/height are non-zero.
206+
"--window-size=1920,1080",
181207
];
182-
context = await chromium.launchPersistentContext(profileDir, {
183-
headless: !headed,
184-
slowMo,
185-
args: [...debugArgs, ...antiAutomationArgs],
186-
...(config.channel ? { channel: config.channel } : {}),
187-
...devicePreset,
188-
});
208+
209+
if (config.antiDetection?.useCloakBrowser) {
210+
// CloakBrowser: stealth Chromium with 33 C++-level patches for DataDome.
211+
// Uses its own Chromium binary — cannot share profiles with real Chrome.
212+
// Profile dir is sibling to the standard profile: <site>-cloak
213+
// Lazy import — only loaded when useCloakBrowser is configured, so adapters
214+
// that don't need it never trigger the ~140MB binary download on install.
215+
const { launchPersistentContext: cloakLaunch } = await import("cloakbrowser");
216+
const cloakProfileDir = this.getProfileDir(`${config.site}-cloak`);
217+
fs.mkdirSync(cloakProfileDir, { recursive: true, mode: 0o700 });
218+
log.info({ site: config.site, cloakProfileDir }, "launching CloakBrowser for DataDome bypass");
219+
context = await cloakLaunch({
220+
userDataDir: cloakProfileDir,
221+
headless: !headed,
222+
humanize: true,
223+
args: antiAutomationArgs,
224+
}) as unknown as BrowserContext;
225+
} else {
226+
context = await chromium.launchPersistentContext(profileDir, {
227+
headless: !headed,
228+
slowMo,
229+
// viewport: null tells Chrome to use its natural window size rather than
230+
// Playwright's default 1280x720 — the fixed Playwright viewport is a known
231+
// bot-detection signal (e.g. DataDome's c.js checks for it).
232+
// Device presets override this with the device's specific viewport.
233+
viewport: null,
234+
args: [...debugArgs, ...antiAutomationArgs],
235+
...(config.channel ? { channel: config.channel } : {}),
236+
...devicePreset,
237+
});
238+
}
189239
}
190240

191241
const page = await context.newPage();
242+
243+
// ── Anti-detection patches (DataDome / similar challenge-based protection) ─
244+
// Applied after context creation so they cover the first navigation too.
245+
//
246+
// stripCOOP: strip Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy
247+
// response headers. Prevents Chromium from restarting its renderer process on
248+
// COOP-protected pages, which would reset --blink-settings pointer emulation.
249+
// Without this fix, (pointer:fine) flips to false mid-navigation on secure.booking.com.
250+
//
251+
// patchPointerMedia: override window.matchMedia so (pointer:fine) = true in headless.
252+
// DataDome's c.js challenge script reads this signal to classify headless Chrome as a bot.
253+
// Uses context.addInitScript so it runs in every frame and survives cross-origin navigations.
254+
if (config.antiDetection?.stripCOOP) {
255+
await context.route("**/*", async (route) => {
256+
try {
257+
const response = await route.fetch();
258+
const headers = { ...response.headers() };
259+
delete headers["cross-origin-opener-policy"];
260+
delete headers["cross-origin-embedder-policy"];
261+
await route.fulfill({ response, headers });
262+
} catch {
263+
await route.continue();
264+
}
265+
});
266+
}
267+
if (config.antiDetection?.patchPointerMedia) {
268+
await context.addInitScript(() => {
269+
// 1. Patch window.matchMedia so (pointer:fine) returns true in headless.
270+
// DataDome's c.js challenge reads this to classify headless Chrome as a bot.
271+
const orig = window.matchMedia.bind(window);
272+
window.matchMedia = (query: string): MediaQueryList => {
273+
const mql = orig(query);
274+
const q = query.replace(/\s+/g, "").toLowerCase();
275+
if (q.includes("pointer:fine")) return Object.assign(Object.create(mql), { matches: true });
276+
if (q.includes("pointer:none")) return Object.assign(Object.create(mql), { matches: false });
277+
if (q.includes("pointer:coarse")) return Object.assign(Object.create(mql), { matches: false });
278+
return mql;
279+
};
280+
281+
// 2. Patch window.outerHeight / outerWidth to simulate a real browser toolbar.
282+
// In headless Chrome, outerHeight === innerHeight (no toolbar rendered).
283+
// DataDome's c.js checks this: zero toolbar height = headless signal.
284+
// Real Chrome on macOS has ~74px of toolbar (address bar + tabs).
285+
const TOOLBAR_HEIGHT = 74;
286+
try {
287+
Object.defineProperty(window, "outerHeight", {
288+
get: () => window.innerHeight + TOOLBAR_HEIGHT,
289+
configurable: true,
290+
});
291+
Object.defineProperty(window, "outerWidth", {
292+
get: () => window.innerWidth,
293+
configurable: true,
294+
});
295+
} catch { /* ignore — may already be non-configurable */ }
296+
});
297+
}
298+
299+
// ── Restore sticky session cookies ────────────────────────────────────────
300+
// DataDome and similar protection systems use session cookies (no expiry) that
301+
// Chrome doesn't persist to disk. We save them manually before closing and
302+
// restore them here so headless launches don't start from scratch every time.
303+
if (config.antiDetection?.saveCookieDomains?.length) {
304+
const savePath = path.join(this.dataDir, "profiles", config.site, "saved-session-cookies.json");
305+
if (fs.existsSync(savePath)) {
306+
try {
307+
const saved = JSON.parse(fs.readFileSync(savePath, "utf8")) as Array<Record<string, unknown>>;
308+
if (saved.length > 0) {
309+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
310+
await context.addCookies(saved as any);
311+
log.info({ site: config.site, cookieCount: saved.length }, "restored sticky session cookies");
312+
}
313+
} catch (err) {
314+
log.warn({ site: config.site, err }, "failed to restore sticky session cookies");
315+
}
316+
}
317+
}
318+
192319
this.sessions.set(config.site, { context, page, config, mode, slowMoMs, debugPort: config.debugPort });
193320
log.info({ site: config.site, mode, debugPort: config.debugPort }, "browser session ready");
194321
return page;

packages/core/src/types.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ export interface SessionConfig {
8888
* crashes in headed mode.
8989
*/
9090
channel?: string | undefined;
91+
/**
92+
* Anti-bot-detection patches. See AdapterConfig.antiDetection for docs.
93+
*/
94+
antiDetection?: {
95+
stripCOOP?: boolean | undefined;
96+
patchPointerMedia?: boolean | undefined;
97+
saveCookieDomains?: string[] | undefined;
98+
useCloakBrowser?: boolean | undefined;
99+
} | undefined;
91100
}
92101

93102
// ─── Adapter config (per-entry in browserkit.config.ts) ────────────────────
@@ -122,6 +131,38 @@ export interface AdapterConfig {
122131
* Accepts: "chrome" | "chrome-beta" | "msedge" | undefined (default: Playwright Chromium)
123132
*/
124133
channel?: string | undefined;
134+
/**
135+
* Anti-bot-detection patches for sites using DataDome or similar challenge-based
136+
* protection (e.g. Booking.com's secure.booking.com).
137+
*
138+
* stripCOOP: Strip Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy
139+
* response headers via a context-level route interceptor. Prevents Chromium from
140+
* restarting its renderer process mid-navigation, which would reset Playwright's
141+
* --blink-settings pointer emulation and cause (pointer:fine) to flip to false.
142+
*
143+
* patchPointerMedia: Override window.matchMedia in every frame so that
144+
* (pointer:fine) returns true in headless. DataDome's challenge script (c.js)
145+
* reads this signal to classify headless Chrome as a bot.
146+
*/
147+
antiDetection?: {
148+
stripCOOP?: boolean | undefined;
149+
patchPointerMedia?: boolean | undefined;
150+
/**
151+
* Domains whose cookies should be saved to disk before the browser closes
152+
* and restored on the next launch. Use for session cookies that DataDome
153+
* or similar systems set but Chrome doesn't persist natively (e.g. `datadome`).
154+
* Example: [".booking.com", "captcha-delivery.com"]
155+
*/
156+
saveCookieDomains?: string[] | undefined;
157+
/**
158+
* Use CloakBrowser (stealth Chromium with 33 C++-level patches) instead of
159+
* Patchright. Downloads ~140MB on first use, cached at ~/.cloakbrowser/.
160+
* Required for sites using DataDome (e.g. Booking.com's secure subdomain).
161+
* NOTE: incompatible with channel:"chrome" — uses its own Chromium binary.
162+
* Profiles created with real Chrome will not work; use a separate profileDir.
163+
*/
164+
useCloakBrowser?: boolean | undefined;
165+
} | undefined;
125166
}
126167

127168
// ─── Handoff ─────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)