imprint-mcp 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/package.json +4 -2
  3. package/prompts/audit-agent.md +4 -2
  4. package/prompts/auth-compile-agent.md +149 -158
  5. package/prompts/build-planning.md +14 -9
  6. package/prompts/compile-agent.md +46 -23
  7. package/prompts/live-verifier-agent.md +101 -0
  8. package/prompts/playbook-compilation.md +8 -1
  9. package/prompts/tool-candidate-detection.md +9 -29
  10. package/src/cli.ts +83 -1
  11. package/src/imprint/audit.ts +219 -20
  12. package/src/imprint/auth-compile-agent.ts +215 -155
  13. package/src/imprint/auth-compile-tools.ts +300 -118
  14. package/src/imprint/auth-verifier.ts +167 -180
  15. package/src/imprint/backend-cache.ts +18 -11
  16. package/src/imprint/backend-ladder.ts +225 -177
  17. package/src/imprint/build-plan.ts +3 -8
  18. package/src/imprint/cdp-browser-fetch.ts +930 -83
  19. package/src/imprint/claude-cli-compile.ts +46 -20
  20. package/src/imprint/codex-cli-compile.ts +436 -139
  21. package/src/imprint/compile-agent-types.ts +15 -10
  22. package/src/imprint/compile-agent.ts +88 -28
  23. package/src/imprint/compile-tools.ts +228 -39
  24. package/src/imprint/compile-verification.ts +187 -0
  25. package/src/imprint/compile.ts +71 -2
  26. package/src/imprint/credential-store.ts +57 -44
  27. package/src/imprint/emit.ts +3 -1
  28. package/src/imprint/freeform-redact.ts +27 -6
  29. package/src/imprint/live-verifier.ts +1665 -0
  30. package/src/imprint/llm.ts +22 -3
  31. package/src/imprint/login.ts +107 -25
  32. package/src/imprint/mcp-compile-server.ts +447 -114
  33. package/src/imprint/mcp-live-verifier-server.ts +359 -0
  34. package/src/imprint/mcp-server.ts +116 -35
  35. package/src/imprint/probe-backends.ts +88 -7
  36. package/src/imprint/recording-request.ts +36 -0
  37. package/src/imprint/request-capture.ts +40 -17
  38. package/src/imprint/runtime.ts +490 -337
  39. package/src/imprint/teach-state.ts +36 -2
  40. package/src/imprint/teach.ts +302 -146
  41. package/src/imprint/tool-candidates.ts +8 -10
  42. package/src/imprint/tool-loader.ts +12 -4
  43. package/src/imprint/types.ts +137 -68
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import CDP from 'chrome-remote-interface';
32
+ import { getDomain } from 'tldts';
32
33
  import { AKAMAI_SENSOR_COOKIE, abckFlag, isAbckValidated } from './bot-defense.ts';
33
34
  import {
34
35
  __registerChromePid,
@@ -93,9 +94,54 @@ export function jarCookiesValidated(cookies: Array<{ name: string; value: string
93
94
  return cookies.some((c) => c.name === 'bm_sv');
94
95
  }
95
96
 
97
+ export function buildNavigationSelectorExpression(selector: string): string {
98
+ return `document.querySelector(${JSON.stringify(selector)}) !== null`;
99
+ }
100
+
101
+ export function buildNavigationClickTargetExpression(selector: string): string {
102
+ return `(() => {
103
+ const target = document.querySelector(${JSON.stringify(selector)});
104
+ if (!(target instanceof Element)) return null;
105
+ target.scrollIntoView({ block: 'center', inline: 'center' });
106
+ const rect = target.getBoundingClientRect();
107
+ if (rect.width <= 0 || rect.height <= 0) return null;
108
+ if (target.matches(':disabled')) return null;
109
+ const x = rect.left + rect.width / 2;
110
+ const y = rect.top + rect.height / 2;
111
+ const hit = document.elementFromPoint(x, y);
112
+ if (hit !== target && !target.contains(hit) && !hit?.contains(target)) return null;
113
+ target.focus();
114
+ return { x, y };
115
+ })()`;
116
+ }
117
+
96
118
  export interface CdpBrowserFetch {
97
119
  /** typeof fetch — executes the request inside the live trusted Chrome page. */
98
120
  readonly fetchImpl: typeof fetch;
121
+ /** Perform a real top-level document navigation and wait for recording-grounded
122
+ * URL/cookie evidence. Page JavaScript owns all browser-minted state. */
123
+ navigate?(
124
+ url: string,
125
+ options?: {
126
+ method?: string;
127
+ headers?: Record<string, string>;
128
+ body?: string;
129
+ waitUntil?: 'domcontentloaded' | 'load';
130
+ timeoutMs?: number;
131
+ pollIntervalMs?: number;
132
+ urlIncludes?: string;
133
+ selector?: string;
134
+ actions?: Array<{ action: 'click'; selector: string }>;
135
+ resultSelector?: string;
136
+ cookie?: { name: string; domain?: string; path?: string };
137
+ },
138
+ ): Promise<Response>;
139
+ /** Snapshot every cookie in this dedicated browser, including cookies minted
140
+ * on origins reached by redirects rather than the bootstrap origin. */
141
+ snapshotCookies?(): Promise<MintedJar['cookies']>;
142
+ /** Inspect the currently rendered page without navigating or mutating browser
143
+ * state. Cookie values are intentionally excluded from this diagnostic view. */
144
+ inspectPage?(): Promise<CdpPageSnapshot>;
99
145
  /** Force the bootstrap navigation + `_abck` validation now; returns the
100
146
  * session cookies so callers can read session tokens (CSRF) for `${state.X}`. */
101
147
  ensureBootstrapped(): Promise<Array<{ name: string; value: string }>>;
@@ -107,6 +153,252 @@ export interface CdpBrowserFetch {
107
153
  close(): Promise<void>;
108
154
  }
109
155
 
156
+ export interface CdpPageSnapshot {
157
+ url: string;
158
+ title: string;
159
+ bodyText: string;
160
+ cookies: Array<{
161
+ name: string;
162
+ domain: string;
163
+ path: string;
164
+ expires?: number;
165
+ httpOnly?: boolean;
166
+ secure?: boolean;
167
+ sameSite?: string;
168
+ }>;
169
+ }
170
+
171
+ export function buildFormPostNavigationExpr(url: string, body: string): string {
172
+ return `(() => {
173
+ const targetUrl = new URL(${JSON.stringify(url)}, location.href).href;
174
+ const params = Array.from(new URLSearchParams(${JSON.stringify(body)}));
175
+ const targetNames = new Set(params.map(([name]) => name));
176
+ const forms = Array.from(document.forms).filter(
177
+ (candidate) => candidate.method.toUpperCase() === 'POST',
178
+ );
179
+ const isSubmitter = (control) => {
180
+ const tag = control.tagName;
181
+ const type = String(control.type || '').toLowerCase();
182
+ return (tag === 'BUTTON' && (type === '' || type === 'submit')) ||
183
+ (tag === 'INPUT' && (type === 'submit' || type === 'image'));
184
+ };
185
+ const canAssign = (control, value) => {
186
+ if (isSubmitter(control)) return String(control.value) === value;
187
+ const type = String(control.type || '').toLowerCase();
188
+ if (type === 'checkbox' || type === 'radio') return String(control.value) === value;
189
+ return !control.disabled && control.name;
190
+ };
191
+ const assign = (control, value) => {
192
+ const type = String(control.type || '').toLowerCase();
193
+ if (type === 'checkbox' || type === 'radio') {
194
+ control.checked = String(control.value) === value;
195
+ } else {
196
+ control.value = value;
197
+ }
198
+ };
199
+ const planForm = (candidate) => {
200
+ const controls = Array.from(candidate.elements);
201
+ const submitterIndexes = controls
202
+ .map((control, index) => isSubmitter(control) && !control.disabled ? index : -1)
203
+ .filter((index) => index >= 0);
204
+ const choices = submitterIndexes.length > 0 ? submitterIndexes : [-1];
205
+ const validPlans = [];
206
+ for (const submitterIndex of choices) {
207
+ const clone = candidate.cloneNode(true);
208
+ const cloneControls = Array.from(clone.elements);
209
+ const used = new Set();
210
+ const assignments = [];
211
+ let possible = true;
212
+ for (const [name, value] of params) {
213
+ const indexes = cloneControls
214
+ .map((control, index) => control.name === name && !used.has(index) ? index : -1)
215
+ .filter((index) => index >= 0);
216
+ const exact = indexes.find((index) => String(cloneControls[index].value) === value);
217
+ const compatible = indexes.find((index) => canAssign(cloneControls[index], value));
218
+ const index = exact ?? compatible;
219
+ if (index === undefined) {
220
+ const input = document.createElement('input');
221
+ input.type = 'hidden';
222
+ input.name = name;
223
+ input.value = value;
224
+ clone.appendChild(input);
225
+ assignments.push({ index: -1, name, value });
226
+ continue;
227
+ }
228
+ if (isSubmitter(cloneControls[index]) && index !== submitterIndex) {
229
+ possible = false;
230
+ break;
231
+ }
232
+ used.add(index);
233
+ assignments.push({ index, name, value });
234
+ if (!isSubmitter(cloneControls[index])) assign(cloneControls[index], value);
235
+ }
236
+ if (!possible) continue;
237
+ const cloneSubmitter = submitterIndex >= 0 ? cloneControls[submitterIndex] : undefined;
238
+ let encoded;
239
+ try {
240
+ encoded = Array.from(new FormData(clone, cloneSubmitter))
241
+ .filter(([name]) => targetNames.has(name))
242
+ .map(([name, value]) => [name, String(value)]);
243
+ } catch {
244
+ continue;
245
+ }
246
+ if (JSON.stringify(encoded) !== JSON.stringify(params)) continue;
247
+ validPlans.push({ submitterIndex, assignments });
248
+ }
249
+ if (validPlans.length !== 1) return null;
250
+ const action = candidate.getAttribute('action') || location.href;
251
+ const actionMatches = new URL(action, location.href).href === targetUrl;
252
+ const candidateControls = Array.from(candidate.elements);
253
+ const namedPairs = params.filter(([name]) =>
254
+ candidateControls.some((control) => control.name === name),
255
+ );
256
+ const exactPairs = params.filter(([name, value]) =>
257
+ candidateControls.some(
258
+ (control) => control.name === name && String(control.value) === value,
259
+ ),
260
+ );
261
+ if (!actionMatches) return null;
262
+ return {
263
+ form: candidate,
264
+ actionMatches,
265
+ namedPairs: namedPairs.length,
266
+ exactPairs: exactPairs.length,
267
+ ...validPlans[0],
268
+ };
269
+ };
270
+ const plans = forms.map(planForm).filter(Boolean);
271
+ plans.sort((left, right) =>
272
+ Number(right.actionMatches) - Number(left.actionMatches) ||
273
+ right.namedPairs - left.namedPairs ||
274
+ right.exactPairs - left.exactPairs,
275
+ );
276
+ const best = plans[0];
277
+ const next = plans[1];
278
+ const tied = best && next && best.actionMatches === next.actionMatches &&
279
+ best.namedPairs === next.namedPairs && best.exactPairs === next.exactPairs;
280
+ const renderedForm = Boolean(best && !tied);
281
+ const form = renderedForm ? best.form : document.createElement('form');
282
+ window.__imprintForm = form;
283
+ form.method = 'POST';
284
+ form.setAttribute('action', targetUrl);
285
+
286
+ if (!renderedForm) {
287
+ form.style.display = 'none';
288
+ for (const [name, value] of params) {
289
+ const input = document.createElement('input');
290
+ input.type = 'hidden';
291
+ input.name = name;
292
+ input.value = value;
293
+ form.appendChild(input);
294
+ }
295
+ const parent = document.body || document.documentElement;
296
+ if (!parent) throw new Error('document has no element that can contain a POST form');
297
+ parent.appendChild(form);
298
+ form.submit();
299
+ return { action: 'submitted', renderedForm: false };
300
+ }
301
+
302
+ const typedFields = [];
303
+ for (const assignment of best.assignments) {
304
+ if (assignment.index < 0) {
305
+ const input = document.createElement('input');
306
+ input.type = 'hidden';
307
+ input.name = assignment.name;
308
+ input.value = assignment.value;
309
+ form.appendChild(input);
310
+ continue;
311
+ }
312
+ const control = form.elements[assignment.index];
313
+ if (isSubmitter(control)) continue;
314
+ const type = String(control.type || '').toLowerCase();
315
+ if (['email', 'password', 'search', 'tel', 'text', 'url'].includes(type) || control.tagName === 'TEXTAREA') {
316
+ control.value = '';
317
+ typedFields.push({ index: assignment.index, value: assignment.value });
318
+ continue;
319
+ }
320
+ assign(control, assignment.value);
321
+ control.dispatchEvent(new Event('input', { bubbles: true }));
322
+ control.dispatchEvent(new Event('change', { bubbles: true }));
323
+ }
324
+ const submitter = best.submitterIndex >= 0 ? form.elements[best.submitterIndex] : null;
325
+ if (submitter) {
326
+ const rect = submitter.getBoundingClientRect();
327
+ const style = getComputedStyle(submitter);
328
+ const visible = rect.width > 0 && rect.height > 0 && style.pointerEvents !== 'none' &&
329
+ style.visibility !== 'hidden';
330
+ if (!visible) throw new Error('recorded form submitter is not interactable');
331
+ window.__imprintSubmitter = submitter;
332
+ submitter.focus();
333
+ submitter.addEventListener('click', () => { window.__imprintFormClickObserved = true; }, { once: true });
334
+ return {
335
+ action: 'click',
336
+ x: rect.left + rect.width / 2,
337
+ y: rect.top + rect.height / 2,
338
+ tag: submitter.tagName,
339
+ type: submitter.type,
340
+ typedFields,
341
+ renderedForm: true,
342
+ };
343
+ }
344
+ if (typeof form.requestSubmit === 'function') form.requestSubmit();
345
+ else form.submit();
346
+ return { action: 'submitted', renderedForm: true };
347
+ })()`;
348
+ }
349
+
350
+ export function validateFormPostNavigationHeaders(headers: Record<string, string>): void {
351
+ const entries = Object.entries(headers);
352
+ const unsupported = entries.filter(
353
+ ([name]) => !['content-type', 'cookie'].includes(name.toLowerCase()),
354
+ );
355
+ if (unsupported.length > 0) {
356
+ throw new Error(
357
+ `top-level form POST cannot preserve request header(s): ${unsupported
358
+ .map(([name]) => name)
359
+ .join(', ')}`,
360
+ );
361
+ }
362
+ const contentTypes = entries
363
+ .filter(([name]) => name.toLowerCase() === 'content-type')
364
+ .map(([, value]) => value);
365
+ if (contentTypes.length !== 1) {
366
+ throw new Error('top-level form POST requires an explicit Content-Type header');
367
+ }
368
+ const mediaType = contentTypes[0]?.split(';', 1)[0]?.trim().toLowerCase();
369
+ if (mediaType !== 'application/x-www-form-urlencoded') {
370
+ throw new Error(
371
+ `top-level form POST requires application/x-www-form-urlencoded, got ${contentTypes[0]}`,
372
+ );
373
+ }
374
+ }
375
+
376
+ export function parseCdpPageInspectionResult(evaluation: {
377
+ result?: { value?: unknown };
378
+ exceptionDetails?: { text?: string; exception?: { description?: string } };
379
+ }): { url: string; title: string; bodyText: string } {
380
+ if (evaluation.exceptionDetails) {
381
+ const detail =
382
+ evaluation.exceptionDetails.exception?.description ??
383
+ evaluation.exceptionDetails.text ??
384
+ 'unknown page evaluation error';
385
+ throw new Error(`rendered page inspection failed: ${detail}`);
386
+ }
387
+ const value = evaluation.result?.value;
388
+ if (!value || typeof value !== 'object') {
389
+ throw new Error('rendered page inspection returned no page snapshot');
390
+ }
391
+ const page = value as { url?: unknown; title?: unknown; bodyText?: unknown };
392
+ if (typeof page.url !== 'string' || !page.url.startsWith('http')) {
393
+ throw new Error('rendered page inspection returned no usable URL');
394
+ }
395
+ return {
396
+ url: page.url,
397
+ title: typeof page.title === 'string' ? page.title : '',
398
+ bodyText: typeof page.bodyText === 'string' ? page.bodyText : '',
399
+ };
400
+ }
401
+
110
402
  export interface CdpBrowserFetchOptions {
111
403
  /** Origin used to resolve relative request URLs + cookie lookups. */
112
404
  baseUrl: string;
@@ -145,6 +437,14 @@ export interface CdpBrowserFetchOptions {
145
437
  sameSite?: string;
146
438
  expires?: number;
147
439
  }>;
440
+ /** Browser storage to restore before page scripts run. Entries are scoped to
441
+ * their recorded origin and applied on every matching document navigation. */
442
+ seedStorage?: Array<{
443
+ origin: string;
444
+ kind: 'localStorage' | 'sessionStorage';
445
+ key: string;
446
+ value: string;
447
+ }>;
148
448
  /** Opt-in: write a cross-origin response's `Set-Cookie` back into the browser
149
449
  * cookie jar (the cross-origin plain-fetch path can't do this itself). OFF by
150
450
  * default — it changes the jar, so it must be a DELIBERATE decision grounded in
@@ -160,6 +460,24 @@ type LaunchedChromium = Awaited<ReturnType<typeof launchChromium>>;
160
460
  type ChromiumLauncher = (opts: Parameters<typeof launchChromium>[0]) => Promise<LaunchedChromium>;
161
461
  type CdpConnector = (port: number) => Promise<CdpClient>;
162
462
 
463
+ export function buildStorageSeedExpression(
464
+ records: NonNullable<CdpBrowserFetchOptions['seedStorage']>,
465
+ ): string {
466
+ return `(() => {
467
+ if (typeof window !== 'undefined' && window.top !== window) return;
468
+ const records = ${JSON.stringify(records)};
469
+ for (const record of records) {
470
+ if (record.origin !== location.origin) continue;
471
+ try {
472
+ const storage = record.kind === 'sessionStorage' ? sessionStorage : localStorage;
473
+ if (storage.getItem(record.key) === null) storage.setItem(record.key, record.value);
474
+ } catch (error) {
475
+ throw new Error('__IMPRINT_STORAGE_SEED_FAILED__' + record.kind + ':' + record.key + ':' + String(error));
476
+ }
477
+ }
478
+ })();`;
479
+ }
480
+
163
481
  let chromiumLauncherForTest: ChromiumLauncher | null = null;
164
482
  let cdpConnectorForTest: CdpConnector | null = null;
165
483
 
@@ -170,49 +488,45 @@ export function __setCdpBrowserFetchHooksForTest(
170
488
  cdpConnectorForTest = hooks?.connectCdp ?? null;
171
489
  }
172
490
 
173
- /** The registrable domain (eTLD+1, approximated) of a host: the last two labels,
174
- * or last three when the penultimate label is a known two-part public suffix
175
- * (co.uk, com.au, …). Used to decide whether a cross-origin request is a SIBLING
176
- * subdomain under the SAME site (e.g. `global.americanexpress.com` vs
177
- * `www.americanexpress.com` → both `americanexpress.com`) versus a genuinely
491
+ export function normalizeCdpResponseHeaders(
492
+ input: Record<string, unknown>,
493
+ ): Record<string, string> {
494
+ const headers: Record<string, string> = {};
495
+ for (const [rawName, rawValue] of Object.entries(input)) {
496
+ if (rawValue == null || /^\d+$/.test(rawName)) continue;
497
+ const name = rawName.toLowerCase();
498
+ const value = String(rawValue).replace(/[\r\n]+/g, ', ');
499
+ try {
500
+ const probe = new Headers();
501
+ probe.set(name, value);
502
+ const normalized = probe.get(name);
503
+ if (normalized !== null) headers[name] = normalized;
504
+ } catch {
505
+ // CDP may expose malformed pseudo/array entries for large response
506
+ // headers. They are metadata only and must not fail navigation replay.
507
+ }
508
+ }
509
+ return headers;
510
+ }
511
+
512
+ /** The registrable domain (eTLD+1) of a host. Private suffixes are enabled so
513
+ * independent tenants such as `foo.github.io` never share browser credentials.
514
+ * Used to decide whether a cross-origin request is a SIBLING
515
+ * subdomain under the SAME site (e.g. `login.example.com` vs
516
+ * `www.example.com` → both `example.com`) versus a genuinely
178
517
  * third-party origin (analytics/CDN). Siblings share the page's anti-bot umbrella
179
518
  * and the recording proves they accept the credentialed cross-origin request, so
180
519
  * they must be issued IN-PAGE (live sensor + Chrome CORS) — not via plain fetch.
181
520
  * Site-agnostic: no host literals, derived purely from the URL structure. */
182
521
  function registrableDomain(host: string): string {
183
- const labels = host.toLowerCase().split('.').filter(Boolean);
184
- if (labels.length <= 2) return labels.join('.');
185
- // Common multi-part public suffixes where eTLD+1 needs three labels.
186
- const twoPartTld = new Set([
187
- 'co.uk',
188
- 'co.jp',
189
- 'co.kr',
190
- 'co.in',
191
- 'co.nz',
192
- 'co.za',
193
- 'com.au',
194
- 'com.br',
195
- 'com.cn',
196
- 'com.mx',
197
- 'com.sg',
198
- 'com.hk',
199
- 'com.tr',
200
- 'org.uk',
201
- 'gov.uk',
202
- 'ac.uk',
203
- 'net.au',
204
- 'org.au',
205
- ]);
206
- const lastTwo = labels.slice(-2).join('.');
207
- if (twoPartTld.has(lastTwo)) return labels.slice(-3).join('.');
208
- return lastTwo;
522
+ return getDomain(host, { allowPrivateDomains: true }) ?? '';
209
523
  }
210
524
 
211
525
  /** Two origins are SIBLINGS when they share a registrable domain but differ in
212
526
  * origin (subdomain and/or scheme). A request to a sibling origin is still under
213
527
  * the page's site/anti-bot umbrella, so it should ride the live in-page sensor +
214
528
  * Chrome's credentialed-CORS engine rather than escaping to plain fetch. */
215
- function isSiblingOrigin(originA: string, originB: string): boolean {
529
+ export function isSiblingOrigin(originA: string, originB: string): boolean {
216
530
  if (originA === originB) return false;
217
531
  try {
218
532
  const a = new URL(originA);
@@ -235,7 +549,7 @@ function isSiblingOrigin(originA: string, originB: string): boolean {
235
549
  * origin's cookie jar, so we must NOT pass a manual Cookie header (it's stripped
236
550
  * by the caller anyway). `fullUrl` may be same-origin OR an absolute sibling URL.
237
551
  *
238
- * Some SPAs monkeypatch window.fetch (AmEx's app.js throws from its patched
552
+ * Some SPAs monkeypatch window.fetch (application code may throw from its patched
239
553
  * version), so we grab the native fetch from a hidden iframe whose fresh
240
554
  * browsing context has the unpatched global but shares the page cookie jar.
241
555
  * The iframe stays alive until the body is read, then is removed.
@@ -251,7 +565,7 @@ function isSiblingOrigin(originA: string, originB: string): boolean {
251
565
  * origin + native fetch + preflight 200; about:blank/srcdoc → `null`). For a
252
566
  * same-origin request the document origin already equals the request origin, so
253
567
  * `about:blank` (faster, no load wait) is fine and this stays unset. */
254
- function buildInPageFetchExpr(
568
+ export function buildInPageFetchExpr(
255
569
  fullUrl: string,
256
570
  method: string,
257
571
  headers: Record<string, string>,
@@ -289,19 +603,22 @@ function buildInPageFetchExpr(
289
603
  } catch (_) {}
290
604
  const ctrl = new AbortController();
291
605
  const to = setTimeout(() => ctrl.abort(), ${reqTimeoutMs});
292
- const r = await _f(${JSON.stringify(fullUrl)}, {
293
- method: ${JSON.stringify(method)},
294
- headers: ${JSON.stringify(headers)},
295
- ${body !== null ? `body: ${JSON.stringify(body)},` : ''}
296
- credentials: 'include',
297
- signal: ctrl.signal,
298
- });
299
- clearTimeout(to);
300
- const text = await r.text();
301
- const h = {};
302
- r.headers.forEach((v, k) => { h[k] = v; });
303
- if (ifr) ifr.remove();
304
- return { ok: true, status: r.status, body: text, headers: h };
606
+ try {
607
+ const r = await _f(${JSON.stringify(fullUrl)}, {
608
+ method: ${JSON.stringify(method)},
609
+ headers: ${JSON.stringify(headers)},
610
+ ${body !== null ? `body: ${JSON.stringify(body)},` : ''}
611
+ credentials: 'include',
612
+ signal: ctrl.signal,
613
+ });
614
+ const text = await r.text();
615
+ const h = {};
616
+ r.headers.forEach((v, k) => { h[k] = v; });
617
+ if (ifr) ifr.remove();
618
+ return { ok: true, status: r.status, body: text, headers: h };
619
+ } finally {
620
+ clearTimeout(to);
621
+ }
305
622
  } catch (e) {
306
623
  if (ifr) try { ifr.remove(); } catch (_) {}
307
624
  return { ok: false, error: String(e) };
@@ -387,6 +704,13 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
387
704
  let bootstrapped = false;
388
705
  let appliedUa: string | undefined;
389
706
  let bootstrapResponseHeaders: Record<string, string> = {};
707
+ let currentPageUrl = navUrl;
708
+ let mainFrameId: string | undefined;
709
+ let lastDocumentResponse: {
710
+ url: string;
711
+ status: number;
712
+ headers: Record<string, string>;
713
+ } | null = null;
390
714
 
391
715
  async function close(): Promise<void> {
392
716
  const c = client;
@@ -397,6 +721,9 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
397
721
  bootstrapped = false;
398
722
  appliedUa = undefined;
399
723
  bootstrapResponseHeaders = {};
724
+ currentPageUrl = navUrl;
725
+ mainFrameId = undefined;
726
+ lastDocumentResponse = null;
400
727
  try {
401
728
  await withTimeout(Promise.resolve(c?.close()), 'CDP client close', 2_000);
402
729
  } catch {
@@ -444,15 +771,32 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
444
771
  await withTimeout(Runtime.enable(), 'CDP Runtime.enable', cdpCommandTimeoutMs);
445
772
  await withTimeout(Network.enable(), 'CDP Network.enable', cdpCommandTimeoutMs);
446
773
  await withTimeout(Page.enable(), 'CDP Page.enable', cdpCommandTimeoutMs);
774
+ if (typeof Page.getFrameTree === 'function') {
775
+ const tree = await withTimeout(
776
+ Page.getFrameTree(),
777
+ 'CDP Page.getFrameTree',
778
+ cdpCommandTimeoutMs,
779
+ );
780
+ mainFrameId = tree.frameTree?.frame?.id;
781
+ }
447
782
  bootstrapResponseHeaders = {};
448
783
  let navDocumentResponseUrl = '';
449
784
  const onResponseReceived = (event: {
450
785
  type?: string;
451
- response?: { url?: string; headers?: Record<string, unknown> };
786
+ frameId?: string;
787
+ response?: { url?: string; status?: number; headers?: Record<string, unknown> };
452
788
  }) => {
453
789
  if (event.type !== 'Document') return;
790
+ if (mainFrameId && event.frameId && event.frameId !== mainFrameId) return;
454
791
  const url = event.response?.url;
455
792
  if (!url) return;
793
+ currentPageUrl = url;
794
+ const headers = normalizeCdpResponseHeaders(event.response?.headers ?? {});
795
+ lastDocumentResponse = {
796
+ url,
797
+ status: Number(event.response?.status ?? 200),
798
+ headers,
799
+ };
456
800
  try {
457
801
  const responseUrl = new URL(url);
458
802
  const targetUrl = new URL(navUrl);
@@ -461,11 +805,6 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
461
805
  return;
462
806
  }
463
807
  navDocumentResponseUrl = url;
464
- const headers: Record<string, string> = {};
465
- for (const [k, v] of Object.entries(event.response?.headers ?? {})) {
466
- if (v == null) continue;
467
- headers[k.toLowerCase()] = String(v);
468
- }
469
808
  bootstrapResponseHeaders = headers;
470
809
  };
471
810
  Network.responseReceived(onResponseReceived);
@@ -525,6 +864,40 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
525
864
  } catch {
526
865
  // best-effort — a headed launch already has a clean UA
527
866
  }
867
+ let storageSeedError: string | undefined;
868
+ if (opts.seedStorage && opts.seedStorage.length > 0) {
869
+ if (
870
+ typeof Page.addScriptToEvaluateOnNewDocument !== 'function' ||
871
+ typeof Runtime.exceptionThrown !== 'function'
872
+ ) {
873
+ throw new Error('CDP cannot restore required browser storage before navigation');
874
+ }
875
+ Runtime.exceptionThrown(
876
+ (event: {
877
+ exceptionDetails?: { text?: string; exception?: { description?: string } };
878
+ }) => {
879
+ const detail =
880
+ event.exceptionDetails?.exception?.description ?? event.exceptionDetails?.text ?? '';
881
+ const marker = '__IMPRINT_STORAGE_SEED_FAILED__';
882
+ const markerIndex = detail.indexOf(marker);
883
+ if (markerIndex >= 0) storageSeedError = detail.slice(markerIndex + marker.length);
884
+ },
885
+ );
886
+ try {
887
+ await withTimeout(
888
+ Page.addScriptToEvaluateOnNewDocument({
889
+ source: buildStorageSeedExpression(opts.seedStorage),
890
+ }),
891
+ 'CDP Page.addScriptToEvaluateOnNewDocument(storage)',
892
+ cdpCommandTimeoutMs,
893
+ );
894
+ log(`registered ${opts.seedStorage.length} browser storage seed entries`);
895
+ } catch (err) {
896
+ throw new Error(
897
+ `browser storage seed registration failed: ${err instanceof Error ? err.message : String(err)}`,
898
+ );
899
+ }
900
+ }
528
901
  // Navigate now (post-override). Page.navigate stalls forever on an Akamai
529
902
  // origin ONLY when the UA still says HeadlessChrome; with the override it
530
903
  // loads normally. Bound the CDP command and proceed regardless — _abck
@@ -546,6 +919,10 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
546
919
  } catch (err) {
547
920
  log(`navigation issue (continuing): ${err instanceof Error ? err.message : String(err)}`);
548
921
  }
922
+ await sleep(0);
923
+ if (typeof storageSeedError === 'string') {
924
+ throw new Error(`browser storage seed execution failed: ${storageSeedError}`);
925
+ }
549
926
  // Give the sensor JS time to start.
550
927
  await sleep(3000);
551
928
  // Drive HUMAN-LIKE interaction until _abck validates (or budget expires).
@@ -661,7 +1038,22 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
661
1038
  const c = await ensure();
662
1039
  const url =
663
1040
  typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
664
- const fullUrl = url.startsWith('http') ? url : `${baseOrigin}${url}`;
1041
+ let pageUrl = currentPageUrl;
1042
+ try {
1043
+ const { result } = await withTimeout(
1044
+ c.Runtime.evaluate({ expression: 'location.href', returnByValue: true }),
1045
+ 'CDP Runtime.evaluate(location.href)',
1046
+ shortCdpTimeoutMs,
1047
+ );
1048
+ if (typeof result.value === 'string' && result.value.startsWith('http')) {
1049
+ pageUrl = result.value;
1050
+ currentPageUrl = pageUrl;
1051
+ }
1052
+ } catch {
1053
+ // Fall back to the last observed Document URL.
1054
+ }
1055
+ const pageOrigin = new URL(pageUrl).origin;
1056
+ const fullUrl = new URL(url, pageUrl).toString();
665
1057
  const method = (init?.method ?? 'GET').toUpperCase();
666
1058
  const headers: Record<string, string> = {};
667
1059
  if (init?.headers) {
@@ -777,8 +1169,8 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
777
1169
  };
778
1170
 
779
1171
  // Cross-origin routing. A request to a SIBLING origin (same registrable domain
780
- // as the live page, e.g. www → global on `americanexpress.com`) is still under
781
- // the page's anti-bot umbrella: the recording shows AmEx CORS-allows the
1172
+ // as the live page, e.g. www → login on `example.com`) is still under
1173
+ // the page's anti-bot umbrella when the site CORS-allows the
782
1174
  // credentialed cross-origin POST (ACAO names the page origin, ACA-credentials
783
1175
  // true), and Akamai edge-403s the SAME request when it leaves the browser as a
784
1176
  // plain fetch (no live sensor, no isTrusted, no `Origin` the edge expects). So
@@ -789,8 +1181,8 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
789
1181
  // ONLY if the browser genuinely CORS-blocks it (in-page payload ok:false). A
790
1182
  // genuinely third-party origin (analytics/CDN) — NOT a sibling — keeps the plain
791
1183
  // fetch path: it isn't behind this site's wall and may not CORS-allow the page.
792
- if (requestOrigin !== baseOrigin) {
793
- if (isSiblingOrigin(requestOrigin, baseOrigin)) {
1184
+ if (requestOrigin !== pageOrigin) {
1185
+ if (isSiblingOrigin(requestOrigin, pageOrigin)) {
794
1186
  log(`cross-origin SIBLING ${method} ${requestOrigin} via in-page fetch`);
795
1187
  // Use a SAME-ORIGIN iframe (loaded from the page origin) for the unpatched
796
1188
  // fetch — NOT about:blank, whose opaque ("null") origin would make the
@@ -799,7 +1191,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
799
1191
  // src gives the iframe the live document's true origin (empirically
800
1192
  // verified). `/favicon.ico` is a universal, cheap same-origin resource; its
801
1193
  // status is irrelevant — only that the iframe inherits the origin.
802
- const sameOriginIframeSrc = `${baseOrigin}/favicon.ico`;
1194
+ const sameOriginIframeSrc = `${pageOrigin}/favicon.ico`;
803
1195
  let payload:
804
1196
  | { ok: true; status: number; body: string; headers: Record<string, string> }
805
1197
  | { ok: false; error: string };
@@ -827,7 +1219,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
827
1219
 
828
1220
  // Execute the fetch INSIDE the trusted page. credentials:'include' so the
829
1221
  // browser attaches the validated session cookies. Uses the shared in-page
830
- // mechanism (iframe-escape for monkeypatched window.fetch — e.g. AmEx's SPA).
1222
+ // mechanism (iframe-escape for a monkeypatched window.fetch).
831
1223
  const payload = await runInPageFetch();
832
1224
  if (!payload.ok) {
833
1225
  // Surface as a network-style failure so the ladder treats it like a fetch throw.
@@ -839,8 +1231,485 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
839
1231
  });
840
1232
  }) as typeof fetch;
841
1233
 
1234
+ const snapshotAllCookies = async (c: CdpClient): Promise<MintedJar['cookies']> => {
1235
+ const cookies: MintedJar['cookies'] = [];
1236
+ try {
1237
+ // With no URL filter CDP returns the dedicated browser's complete jar.
1238
+ // This includes host-only cookies minted on relying-party origins reached
1239
+ // through redirects, which a bootstrap-origin-only query cannot see.
1240
+ const res = await withTimeout(
1241
+ c.Network.getCookies({}),
1242
+ 'CDP Network.getCookies(all origins)',
1243
+ cdpCommandTimeoutMs,
1244
+ );
1245
+ for (const ck of res.cookies as unknown as Array<Record<string, unknown>>) {
1246
+ cookies.push({
1247
+ name: ck.name as string,
1248
+ value: ck.value as string,
1249
+ domain: ck.domain as string,
1250
+ path: (ck.path as string) ?? '/',
1251
+ expires:
1252
+ typeof ck.expires === 'number' && ck.expires > 0 ? (ck.expires as number) : undefined,
1253
+ httpOnly: ck.httpOnly as boolean | undefined,
1254
+ secure: ck.secure as boolean | undefined,
1255
+ sameSite: ck.sameSite as string | undefined,
1256
+ });
1257
+ }
1258
+ } catch {
1259
+ // best-effort
1260
+ }
1261
+ return cookies;
1262
+ };
1263
+
1264
+ const readCurrentPageUrl = async (c: CdpClient): Promise<string> => {
1265
+ try {
1266
+ const { result } = await withTimeout(
1267
+ c.Runtime.evaluate({ expression: 'location.href', returnByValue: true }),
1268
+ 'CDP Runtime.evaluate(location.href)',
1269
+ shortCdpTimeoutMs,
1270
+ );
1271
+ if (typeof result.value === 'string' && result.value.startsWith('http')) {
1272
+ currentPageUrl = result.value;
1273
+ }
1274
+ } catch {
1275
+ // Fall back to the last Document response observed by Network.
1276
+ }
1277
+ return currentPageUrl;
1278
+ };
1279
+
1280
+ const dispatchTrustedClick = async (
1281
+ c: CdpClient,
1282
+ x: number,
1283
+ y: number,
1284
+ label: string,
1285
+ timeoutMs: number,
1286
+ ): Promise<void> => {
1287
+ await withTimeout(c.Page.bringToFront(), `CDP Page.bringToFront(${label})`, timeoutMs);
1288
+ const timestamp = Date.now() / 1000;
1289
+ await withTimeout(
1290
+ c.Input.dispatchMouseEvent({ type: 'mouseMoved', x, y, timestamp }),
1291
+ `CDP Input.dispatchMouseEvent(${label} mouseMoved)`,
1292
+ timeoutMs,
1293
+ );
1294
+ await sleep(100);
1295
+ await withTimeout(
1296
+ c.Input.dispatchMouseEvent({
1297
+ type: 'mousePressed',
1298
+ button: 'left',
1299
+ buttons: 1,
1300
+ clickCount: 1,
1301
+ x,
1302
+ y,
1303
+ timestamp,
1304
+ }),
1305
+ `CDP Input.dispatchMouseEvent(${label} mousePressed)`,
1306
+ timeoutMs,
1307
+ );
1308
+ await sleep(50);
1309
+ await withTimeout(
1310
+ c.Input.dispatchMouseEvent({
1311
+ type: 'mouseReleased',
1312
+ button: 'left',
1313
+ clickCount: 1,
1314
+ x,
1315
+ y,
1316
+ timestamp: Date.now() / 1000,
1317
+ }),
1318
+ `CDP Input.dispatchMouseEvent(${label} mouseReleased)`,
1319
+ timeoutMs,
1320
+ );
1321
+ };
1322
+
1323
+ const navigate = async (
1324
+ rawUrl: string,
1325
+ options: {
1326
+ method?: string;
1327
+ headers?: Record<string, string>;
1328
+ body?: string;
1329
+ waitUntil?: 'domcontentloaded' | 'load';
1330
+ timeoutMs?: number;
1331
+ pollIntervalMs?: number;
1332
+ urlIncludes?: string;
1333
+ selector?: string;
1334
+ actions?: Array<{ action: 'click'; selector: string }>;
1335
+ resultSelector?: string;
1336
+ cookie?: { name: string; domain?: string; path?: string };
1337
+ } = {},
1338
+ ): Promise<Response> => {
1339
+ const c = await ensure();
1340
+ const startUrl = await readCurrentPageUrl(c);
1341
+ const targetUrl = new URL(rawUrl, startUrl).toString();
1342
+ const method = (options.method ?? 'GET').toUpperCase();
1343
+ if (method !== 'GET' && method !== 'POST') {
1344
+ throw new Error(`top-level browser navigation does not support ${method}`);
1345
+ }
1346
+ if (method === 'POST') {
1347
+ validateFormPostNavigationHeaders(options.headers ?? {});
1348
+ }
1349
+ const timeoutMs = options.timeoutMs ?? 60_000;
1350
+ const pollIntervalMs = options.pollIntervalMs ?? 250;
1351
+ const lifecyclePromise = (
1352
+ options.waitUntil === 'domcontentloaded'
1353
+ ? c.Page.domContentEventFired()
1354
+ : c.Page.loadEventFired()
1355
+ ).then(
1356
+ () => true,
1357
+ () => false,
1358
+ );
1359
+
1360
+ lastDocumentResponse = null;
1361
+ if (method === 'GET') {
1362
+ await withTimeout(
1363
+ c.Page.navigate({ url: targetUrl }),
1364
+ `CDP Page.navigate(${new URL(targetUrl).origin})`,
1365
+ Math.min(timeoutMs, cdpCommandTimeoutMs),
1366
+ );
1367
+ } else {
1368
+ const evaluation = await withTimeout(
1369
+ c.Runtime.evaluate({
1370
+ expression: buildFormPostNavigationExpr(targetUrl, options.body ?? ''),
1371
+ returnByValue: true,
1372
+ }),
1373
+ `CDP Runtime.evaluate(form POST to ${new URL(targetUrl).origin})`,
1374
+ Math.min(timeoutMs, cdpCommandTimeoutMs),
1375
+ );
1376
+ if (evaluation.exceptionDetails) {
1377
+ const detail =
1378
+ evaluation.exceptionDetails.exception?.description ?? evaluation.exceptionDetails.text;
1379
+ throw new Error(`browser form preparation failed: ${detail}`);
1380
+ }
1381
+ const directive = evaluation.result.value as
1382
+ | {
1383
+ action?: unknown;
1384
+ x?: unknown;
1385
+ y?: unknown;
1386
+ tag?: unknown;
1387
+ type?: unknown;
1388
+ typedFields?: unknown;
1389
+ }
1390
+ | undefined;
1391
+ if (
1392
+ directive?.action === 'click' &&
1393
+ typeof directive.x === 'number' &&
1394
+ typeof directive.y === 'number'
1395
+ ) {
1396
+ await withTimeout(
1397
+ c.Page.bringToFront(),
1398
+ 'CDP Page.bringToFront(form)',
1399
+ cdpCommandTimeoutMs,
1400
+ );
1401
+ const typedFields = Array.isArray(directive.typedFields)
1402
+ ? directive.typedFields.filter(
1403
+ (field): field is { index: number; value: string } =>
1404
+ Boolean(field) &&
1405
+ typeof field === 'object' &&
1406
+ typeof (field as { index?: unknown }).index === 'number' &&
1407
+ typeof (field as { value?: unknown }).value === 'string',
1408
+ )
1409
+ : [];
1410
+ for (const field of typedFields) {
1411
+ const focused = await withTimeout(
1412
+ c.Runtime.evaluate({
1413
+ expression: `(() => {
1414
+ const control = window.__imprintForm?.elements?.[${field.index}];
1415
+ if (!control) return false;
1416
+ control.focus();
1417
+ control.value = '';
1418
+ return document.activeElement === control;
1419
+ })()`,
1420
+ returnByValue: true,
1421
+ }),
1422
+ 'CDP Runtime.evaluate(form field focus)',
1423
+ cdpCommandTimeoutMs,
1424
+ );
1425
+ if (focused.result.value !== true) {
1426
+ throw new Error(`browser form field at index ${field.index} could not be focused`);
1427
+ }
1428
+ for (const character of field.value) {
1429
+ await withTimeout(
1430
+ c.Input.dispatchKeyEvent({ type: 'char', text: character }),
1431
+ 'CDP Input.dispatchKeyEvent(form character)',
1432
+ cdpCommandTimeoutMs,
1433
+ );
1434
+ await sleep(15 + Math.floor(Math.random() * 26));
1435
+ }
1436
+ }
1437
+ const focusProbe = await withTimeout(
1438
+ c.Runtime.evaluate({
1439
+ expression:
1440
+ 'window.__imprintSubmitter?.focus(); document.activeElement === window.__imprintSubmitter',
1441
+ returnByValue: true,
1442
+ }),
1443
+ 'CDP Runtime.evaluate(form focus restore)',
1444
+ cdpCommandTimeoutMs,
1445
+ );
1446
+ const targetProbe = await withTimeout(
1447
+ c.Runtime.evaluate({
1448
+ expression: `(() => {
1449
+ const target = window.__imprintSubmitter;
1450
+ if (!target) return null;
1451
+ const rect = target.getBoundingClientRect();
1452
+ const x = rect.left + rect.width / 2;
1453
+ const y = rect.top + rect.height / 2;
1454
+ const hit = document.elementFromPoint(x, y);
1455
+ return { x, y, hit: hit === target || target.contains(hit) };
1456
+ })()`,
1457
+ returnByValue: true,
1458
+ }),
1459
+ 'CDP Runtime.evaluate(form click target)',
1460
+ cdpCommandTimeoutMs,
1461
+ );
1462
+ const liveTarget = targetProbe.result.value as {
1463
+ x?: unknown;
1464
+ y?: unknown;
1465
+ hit?: unknown;
1466
+ } | null;
1467
+ if (typeof liveTarget?.x !== 'number' || typeof liveTarget.y !== 'number') {
1468
+ throw new Error('browser form submit control lost its rendered position');
1469
+ }
1470
+ const clickX = liveTarget.x;
1471
+ const clickY = liveTarget.y;
1472
+ log(
1473
+ `form POST: trusted click on ${String(directive.tag ?? 'control')} type=${String(directive.type ?? 'unknown')} focused=${focusProbe.result.value === true} hit=${liveTarget.hit === true}`,
1474
+ );
1475
+ await dispatchTrustedClick(c, clickX, clickY, 'form', cdpCommandTimeoutMs);
1476
+ await sleep(250);
1477
+ const clickProbe = await withTimeout(
1478
+ c.Runtime.evaluate({
1479
+ expression: 'window.__imprintFormClickObserved === true',
1480
+ returnByValue: true,
1481
+ }),
1482
+ 'CDP Runtime.evaluate(form click probe)',
1483
+ cdpCommandTimeoutMs,
1484
+ ).catch(() => null);
1485
+ let clickObserved = clickProbe?.result.value === true;
1486
+ const navigationObserved = lastDocumentResponse !== null;
1487
+ log(`form POST: click observed=${clickObserved} navigation observed=${navigationObserved}`);
1488
+ if (!clickObserved && !navigationObserved) {
1489
+ await withTimeout(
1490
+ c.Input.dispatchKeyEvent({
1491
+ type: 'rawKeyDown',
1492
+ key: 'Enter',
1493
+ code: 'Enter',
1494
+ windowsVirtualKeyCode: 13,
1495
+ nativeVirtualKeyCode: 13,
1496
+ }),
1497
+ 'CDP Input.dispatchKeyEvent(form Enter down)',
1498
+ cdpCommandTimeoutMs,
1499
+ );
1500
+ await sleep(50);
1501
+ await withTimeout(
1502
+ c.Input.dispatchKeyEvent({
1503
+ type: 'keyUp',
1504
+ key: 'Enter',
1505
+ code: 'Enter',
1506
+ windowsVirtualKeyCode: 13,
1507
+ nativeVirtualKeyCode: 13,
1508
+ }),
1509
+ 'CDP Input.dispatchKeyEvent(form Enter up)',
1510
+ cdpCommandTimeoutMs,
1511
+ );
1512
+ await sleep(250);
1513
+ const keyProbe = await withTimeout(
1514
+ c.Runtime.evaluate({
1515
+ expression: 'window.__imprintFormClickObserved === true',
1516
+ returnByValue: true,
1517
+ }),
1518
+ 'CDP Runtime.evaluate(form key probe)',
1519
+ cdpCommandTimeoutMs,
1520
+ ).catch(() => null);
1521
+ clickObserved = keyProbe?.result.value === true;
1522
+ log(`form POST: Enter fallback click observed=${clickObserved}`);
1523
+ }
1524
+ }
1525
+ }
1526
+
1527
+ const selectorExists = async (selector?: string): Promise<boolean> => {
1528
+ if (!selector) return true;
1529
+ const evaluated = await withTimeout(
1530
+ c.Runtime.evaluate({
1531
+ expression: buildNavigationSelectorExpression(selector),
1532
+ returnByValue: true,
1533
+ }),
1534
+ 'CDP Runtime.evaluate(navigation selector)',
1535
+ cdpCommandTimeoutMs,
1536
+ );
1537
+ return evaluated.result.value === true;
1538
+ };
1539
+ const hasPredicate = Boolean(options.urlIncludes || options.selector || options.cookie);
1540
+ const deadline = Date.now() + timeoutMs;
1541
+ if (!hasPredicate) {
1542
+ await withTimeout(lifecyclePromise, `CDP Page.${options.waitUntil ?? 'load'}`, timeoutMs);
1543
+ } else {
1544
+ let lastUrl = targetUrl;
1545
+ while (Date.now() < deadline) {
1546
+ lastUrl = await readCurrentPageUrl(c);
1547
+ const urlMatches = !options.urlIncludes || lastUrl.includes(options.urlIncludes);
1548
+ const selectorMatches = await selectorExists(options.selector);
1549
+ let cookieMatches = !options.cookie;
1550
+ if (options.cookie) {
1551
+ const expectedDomain = options.cookie.domain?.replace(/^\./, '').toLowerCase();
1552
+ const allCookies = await snapshotAllCookies(c);
1553
+ cookieMatches = allCookies.some((cookie) => {
1554
+ if (cookie.name !== options.cookie?.name) return false;
1555
+ if (
1556
+ expectedDomain &&
1557
+ cookie.domain.replace(/^\./, '').toLowerCase() !== expectedDomain
1558
+ ) {
1559
+ return false;
1560
+ }
1561
+ return !options.cookie?.path || cookie.path === options.cookie.path;
1562
+ });
1563
+ }
1564
+ if (urlMatches && selectorMatches && cookieMatches) break;
1565
+ await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
1566
+ }
1567
+
1568
+ await withTimeout(
1569
+ lifecyclePromise,
1570
+ `CDP Page.${options.waitUntil ?? 'load'} after navigation predicate`,
1571
+ Math.max(1, deadline - Date.now()),
1572
+ );
1573
+ // The lifecycle event precedes some async form/challenge initialization.
1574
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
1575
+
1576
+ const finalUrl = await readCurrentPageUrl(c);
1577
+ const finalSelectorMatches = await selectorExists(options.selector);
1578
+ const allCookies = await snapshotAllCookies(c);
1579
+ const expectedDomain = options.cookie?.domain?.replace(/^\./, '').toLowerCase();
1580
+ const matchedCookie = !options.cookie
1581
+ ? true
1582
+ : allCookies.some((cookie) => {
1583
+ if (cookie.name !== options.cookie?.name) return false;
1584
+ if (
1585
+ expectedDomain &&
1586
+ cookie.domain.replace(/^\./, '').toLowerCase() !== expectedDomain
1587
+ ) {
1588
+ return false;
1589
+ }
1590
+ return !options.cookie?.path || cookie.path === options.cookie.path;
1591
+ });
1592
+ if (
1593
+ (options.urlIncludes && !finalUrl.includes(options.urlIncludes)) ||
1594
+ !finalSelectorMatches ||
1595
+ !matchedCookie
1596
+ ) {
1597
+ const criteria = [
1598
+ options.urlIncludes ? `URL containing ${JSON.stringify(options.urlIncludes)}` : null,
1599
+ options.selector ? `CSS selector ${JSON.stringify(options.selector)}` : null,
1600
+ options.cookie ? `cookie ${options.cookie.name}` : null,
1601
+ ]
1602
+ .filter(Boolean)
1603
+ .join(' and ');
1604
+ throw new Error(
1605
+ `browser navigation timed out after ${timeoutMs}ms waiting for ${criteria}; final URL was ${finalUrl}`,
1606
+ );
1607
+ }
1608
+ }
1609
+
1610
+ for (const interaction of options.actions ?? []) {
1611
+ if (interaction.action !== 'click') continue;
1612
+ const target = await withTimeout(
1613
+ c.Runtime.evaluate({
1614
+ expression: buildNavigationClickTargetExpression(interaction.selector),
1615
+ returnByValue: true,
1616
+ }),
1617
+ `CDP Runtime.evaluate(navigation click target ${JSON.stringify(interaction.selector)})`,
1618
+ Math.max(1, deadline - Date.now()),
1619
+ ).catch(() => null);
1620
+ const point = target?.result.value as { x?: unknown; y?: unknown } | null | undefined;
1621
+ if (typeof point?.x !== 'number' || typeof point.y !== 'number') {
1622
+ throw new Error(
1623
+ `browser navigation action could not find a visible click target for ${JSON.stringify(interaction.selector)}`,
1624
+ );
1625
+ }
1626
+ await dispatchTrustedClick(
1627
+ c,
1628
+ point.x,
1629
+ point.y,
1630
+ 'navigation',
1631
+ Math.max(1, deadline - Date.now()),
1632
+ );
1633
+ await sleep(150);
1634
+ }
1635
+
1636
+ if (options.resultSelector) {
1637
+ let matched = false;
1638
+ while (Date.now() < deadline) {
1639
+ matched = await selectorExists(options.resultSelector).catch(() => false);
1640
+ if (matched) break;
1641
+ await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
1642
+ }
1643
+ if (!matched) {
1644
+ throw new Error(
1645
+ `browser navigation timed out after ${timeoutMs}ms waiting for post-action CSS selector ${JSON.stringify(options.resultSelector)}`,
1646
+ );
1647
+ }
1648
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
1649
+ }
1650
+
1651
+ currentPageUrl = await readCurrentPageUrl(c);
1652
+ let html = '';
1653
+ try {
1654
+ const { result } = await withTimeout(
1655
+ c.Runtime.evaluate({
1656
+ expression: 'document.documentElement.outerHTML',
1657
+ returnByValue: true,
1658
+ }),
1659
+ 'CDP Runtime.evaluate(document HTML after navigate)',
1660
+ cdpCommandTimeoutMs,
1661
+ );
1662
+ html = String(result.value ?? '');
1663
+ } catch {
1664
+ // A navigation can still be successful when the final document is empty.
1665
+ }
1666
+ const doc = lastDocumentResponse as {
1667
+ url: string;
1668
+ status: number;
1669
+ headers: Record<string, string>;
1670
+ } | null;
1671
+ const status = doc && doc.status >= 200 && doc.status <= 599 ? doc.status : 200;
1672
+ return new Response(html, {
1673
+ status,
1674
+ headers: new Headers({
1675
+ ...(doc?.headers ?? {}),
1676
+ 'x-imprint-final-url': currentPageUrl,
1677
+ }),
1678
+ });
1679
+ };
1680
+
842
1681
  return {
843
1682
  fetchImpl,
1683
+ navigate,
1684
+ async inspectPage(): Promise<CdpPageSnapshot> {
1685
+ const c = await ensure();
1686
+ const evaluation = await withTimeout(
1687
+ c.Runtime.evaluate({
1688
+ expression:
1689
+ '({ url: location.href, title: document.title, bodyText: document.body?.innerText ?? "" })',
1690
+ returnByValue: true,
1691
+ }),
1692
+ 'CDP Runtime.evaluate(rendered page inspection)',
1693
+ cdpCommandTimeoutMs,
1694
+ );
1695
+ const page = parseCdpPageInspectionResult(evaluation);
1696
+ const cookies = await snapshotAllCookies(c);
1697
+ return {
1698
+ ...page,
1699
+ cookies: cookies.map((cookie) => ({
1700
+ name: cookie.name,
1701
+ domain: cookie.domain,
1702
+ path: cookie.path,
1703
+ expires: cookie.expires,
1704
+ httpOnly: cookie.httpOnly,
1705
+ secure: cookie.secure,
1706
+ sameSite: cookie.sameSite,
1707
+ })),
1708
+ };
1709
+ },
1710
+ async snapshotCookies() {
1711
+ return snapshotAllCookies(await ensure());
1712
+ },
844
1713
  async ensureBootstrapped() {
845
1714
  const c = await ensure();
846
1715
  try {
@@ -859,29 +1728,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
859
1728
  },
860
1729
  async mintJar(): Promise<MintedJar> {
861
1730
  const c = await ensure();
862
- const cookies: MintedJar['cookies'] = [];
863
- try {
864
- const res = await withTimeout(
865
- c.Network.getCookies({ urls: [baseOrigin] }),
866
- 'CDP Network.getCookies',
867
- cdpCommandTimeoutMs,
868
- );
869
- for (const ck of res.cookies as unknown as Array<Record<string, unknown>>) {
870
- cookies.push({
871
- name: ck.name as string,
872
- value: ck.value as string,
873
- domain: ck.domain as string,
874
- path: (ck.path as string) ?? '/',
875
- expires:
876
- typeof ck.expires === 'number' && ck.expires > 0 ? (ck.expires as number) : undefined,
877
- httpOnly: ck.httpOnly as boolean | undefined,
878
- secure: ck.secure as boolean | undefined,
879
- sameSite: ck.sameSite as string | undefined,
880
- });
881
- }
882
- } catch {
883
- // best-effort
884
- }
1731
+ const cookies = await snapshotAllCookies(c);
885
1732
  let html = '';
886
1733
  try {
887
1734
  const { result } = await withTimeout(