unbrowse 2.12.2 → 2.12.7

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 (64) hide show
  1. package/README.md +8 -44
  2. package/dist/cli.js +514 -20723
  3. package/package.json +4 -10
  4. package/runtime-src/api/routes.ts +15 -801
  5. package/runtime-src/auth/index.ts +32 -142
  6. package/runtime-src/capture/index.ts +101 -436
  7. package/runtime-src/cli.ts +371 -956
  8. package/runtime-src/client/index.ts +29 -622
  9. package/runtime-src/execution/index.ts +85 -345
  10. package/runtime-src/graph/index.ts +10 -128
  11. package/runtime-src/intent-match.ts +27 -27
  12. package/runtime-src/kuri/client.ts +82 -543
  13. package/runtime-src/orchestrator/index.ts +462 -2246
  14. package/runtime-src/reverse-engineer/index.ts +22 -220
  15. package/runtime-src/runtime/local-server.ts +16 -149
  16. package/runtime-src/runtime/paths.ts +5 -9
  17. package/runtime-src/runtime/setup.ts +1 -52
  18. package/runtime-src/server.ts +11 -6
  19. package/runtime-src/transform/schema-hints.ts +358 -0
  20. package/runtime-src/types/skill.ts +2 -49
  21. package/runtime-src/verification/index.ts +0 -15
  22. package/runtime-src/version.ts +13 -13
  23. package/vendor/kuri/darwin-arm64/kuri +0 -0
  24. package/vendor/kuri/darwin-x64/kuri +0 -0
  25. package/vendor/kuri/linux-arm64/kuri +0 -0
  26. package/vendor/kuri/linux-x64/kuri +0 -0
  27. package/bin/unbrowse-wrapper.mjs +0 -39
  28. package/bin/unbrowse.js +0 -38
  29. package/runtime-src/analytics-session.ts +0 -33
  30. package/runtime-src/api/browse-index.ts +0 -254
  31. package/runtime-src/api/browse-session.ts +0 -179
  32. package/runtime-src/api/browse-submit.ts +0 -455
  33. package/runtime-src/auth/runtime.ts +0 -116
  34. package/runtime-src/browser/index.ts +0 -635
  35. package/runtime-src/browser/types.ts +0 -41
  36. package/runtime-src/capture/prefetch.ts +0 -122
  37. package/runtime-src/capture/rsc.ts +0 -45
  38. package/runtime-src/cli/shortcuts.ts +0 -273
  39. package/runtime-src/client/graph-client.ts +0 -99
  40. package/runtime-src/execution/robots.ts +0 -167
  41. package/runtime-src/execution/search-forms.ts +0 -188
  42. package/runtime-src/graph/planner.ts +0 -411
  43. package/runtime-src/graph/session.ts +0 -294
  44. package/runtime-src/graph/trace-store.ts +0 -136
  45. package/runtime-src/indexer/index.ts +0 -480
  46. package/runtime-src/orchestrator/browser-agent.ts +0 -374
  47. package/runtime-src/orchestrator/dag-advisor.ts +0 -59
  48. package/runtime-src/orchestrator/dag-feedback.ts +0 -256
  49. package/runtime-src/orchestrator/first-pass-action.ts +0 -362
  50. package/runtime-src/orchestrator/passive-publish.ts +0 -152
  51. package/runtime-src/orchestrator/timing-economics.ts +0 -80
  52. package/runtime-src/payments/cascade.ts +0 -137
  53. package/runtime-src/payments/index.ts +0 -268
  54. package/runtime-src/payments/wallet.ts +0 -33
  55. package/runtime-src/reverse-engineer/description-prompt.ts +0 -132
  56. package/runtime-src/router.ts +0 -17
  57. package/runtime-src/runtime/browser-access.ts +0 -11
  58. package/runtime-src/runtime/browser-host.ts +0 -48
  59. package/runtime-src/runtime/lifecycle.ts +0 -17
  60. package/runtime-src/runtime/supervisor.ts +0 -69
  61. package/runtime-src/single-binary.ts +0 -141
  62. package/runtime-src/telemetry.ts +0 -253
  63. package/runtime-src/verification/matrix.ts +0 -30
  64. package/scripts/postinstall.mjs +0 -81
@@ -2,19 +2,6 @@ import * as kuri from "../kuri/client.js";
2
2
  import { nanoid } from "nanoid";
3
3
  import { getRegistrableDomain } from "../domain.js";
4
4
  import { log } from "../logger.js";
5
- import type { BrowserAccessConfig } from "../runtime/browser-access.js";
6
- import { DEFAULT_BROWSER_ACCESS } from "../runtime/browser-access.js";
7
-
8
- /**
9
- * Check whether the current BrowserAccessConfig allows browser-based capture.
10
- * Returns true when the default or fallback path is "unbrowse" or "direct"
11
- * (i.e. not proxy-only), meaning we can launch a local browser via Kuri.
12
- */
13
- export function isBrowserAccessAvailable(
14
- config: BrowserAccessConfig = DEFAULT_BROWSER_ACCESS,
15
- ): boolean {
16
- return config.default_path !== "proxy" || config.fallback_path !== "proxy";
17
- }
18
5
 
19
6
  // BUG-GC-012: Use a real Chrome UA — HeadlessChrome is actively blocked by Google and others.
20
7
  const CHROME_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
@@ -27,34 +14,10 @@ const waitQueue: Array<() => void> = [];
27
14
  // Active tab registry — tracked for graceful shutdown
28
15
  const activeTabRegistry = new Set<string>();
29
16
 
30
- // Tracks tabs where scriptInject has been registered (persistent across navigations)
31
- const interceptorInjectedTabs = new Set<string>();
32
-
33
17
  // Hard timeout per capture: 90s prevents stuck tabs from holding slots forever
34
18
  const CAPTURE_TIMEOUT_MS = 90_000;
35
19
  const CAPTURE_NAV_TIMEOUT_MS = 20_000;
36
20
 
37
- /**
38
- * Races `fn()` against an AbortSignal so that each CDP phase exits immediately
39
- * when the overall capture timeout fires — instead of waiting for kuri's own
40
- * 30s per-request timeout to expire (which caused 120s+ hangs, bug #113).
41
- */
42
- export function withBrowserPhaseTimeout<T>(
43
- signal: AbortSignal,
44
- label: string,
45
- fn: () => Promise<T>,
46
- ): Promise<T> {
47
- if (signal.aborted) return Promise.reject(new Error(`capture phase '${label}' timed out`));
48
- return new Promise<T>((resolve, reject) => {
49
- const onAbort = () => reject(new Error(`capture phase '${label}' timed out`));
50
- signal.addEventListener("abort", onAbort, { once: true });
51
- fn().then(
52
- (val) => { signal.removeEventListener("abort", onAbort); resolve(val); },
53
- (err) => { signal.removeEventListener("abort", onAbort); reject(err); },
54
- );
55
- });
56
- }
57
-
58
21
  // Client hint headers to avoid headless detection
59
22
  const CLIENT_HINT_HEADERS: Record<string, string> = {
60
23
  "sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"',
@@ -87,7 +50,7 @@ async function acquireTabSlot(): Promise<void> {
87
50
  }
88
51
 
89
52
  function releaseTabSlot(tabId?: string): void {
90
- if (tabId) { activeTabRegistry.delete(tabId); interceptorInjectedTabs.delete(tabId); }
53
+ if (tabId) activeTabRegistry.delete(tabId);
91
54
  activeTabs--;
92
55
  const next = waitQueue.shift();
93
56
  if (next) next();
@@ -116,11 +79,6 @@ export interface CaptureResult {
116
79
  ws_messages?: CapturedWsMessage[];
117
80
  html?: string;
118
81
  js_bundles?: Map<string, string>;
119
- graph_session?: {
120
- observed_operations: string[];
121
- known_bindings: Record<string, unknown>;
122
- suggested_next: string[];
123
- };
124
82
  }
125
83
 
126
84
  export interface RawRequest {
@@ -132,29 +90,6 @@ export interface RawRequest {
132
90
  response_headers: Record<string, string>;
133
91
  response_body?: string;
134
92
  timestamp: string;
135
- /** Query hook bridge: which action step triggered this request (#114) */
136
- triggered_by_step?: number;
137
- triggered_by_action?: string;
138
- triggered_by_ref?: string;
139
- }
140
-
141
- export interface QueryHookEvent {
142
- step_index: number;
143
- action_type: string;
144
- selector: string;
145
- requests_triggered: Array<{ url: string; method: string }>;
146
- timestamp: number;
147
- }
148
-
149
- interface ExtensionEntry {
150
- url: string;
151
- method: string;
152
- type: string; // "xmlhttprequest", "fetch", "main_frame", etc.
153
- statusCode?: number;
154
- requestHeaders?: Array<{ name: string; value: string }>;
155
- responseHeaders?: Array<{ name: string; value: string }>;
156
- tabId?: number;
157
- timestamp: number;
158
93
  }
159
94
 
160
95
  export type CapturedCookie = {
@@ -347,11 +282,11 @@ export function hasUsefulCapturedResponses(
347
282
  * Inject a fetch/XHR interceptor into the page to capture request/response data.
348
283
  * Returns captured entries via __unbrowse_intercepted global.
349
284
  */
350
- export const INTERCEPTOR_SCRIPT = `(function() {
285
+ const INTERCEPTOR_SCRIPT = `(function() {
351
286
  if (window.__unbrowse_interceptor_installed) return;
352
287
  window.__unbrowse_interceptor_installed = true;
353
288
  window.__unbrowse_intercepted = [];
354
- var MAX_BODY = 2 * 1024 * 1024;
289
+ var MAX_BODY = 512 * 1024;
355
290
  var MAX_JS_BODY = 2 * 1024 * 1024;
356
291
  var MAX_ENTRIES = 500;
357
292
 
@@ -375,10 +310,9 @@ export const INTERCEPTOR_SCRIPT = `(function() {
375
310
  if (window.__unbrowse_intercepted.length >= MAX_ENTRIES) return response;
376
311
  var ct = response.headers.get('content-type') || '';
377
312
  var isJs = ct.indexOf('javascript') !== -1 || /\\.js(\\?|$)/.test(url);
378
- var isData = ct.indexOf('json') !== -1 || ct.indexOf('application/x-protobuf') !== -1 ||
379
- ct.indexOf('text/plain') !== -1 ||
380
- url.indexOf('batchexecute') !== -1 || url.indexOf('/api/') !== -1 ||
381
- url.indexOf('graphql') !== -1 || url.indexOf('voyager') !== -1;
313
+ var isData = ct.indexOf('application/json') !== -1 || ct.indexOf('+json') !== -1 ||
314
+ ct.indexOf('application/x-protobuf') !== -1 || ct.indexOf('text/plain') !== -1 ||
315
+ url.indexOf('batchexecute') !== -1 || url.indexOf('/api/') !== -1;
382
316
  if (!isJs && !isData) return response;
383
317
  if (/\\.(css|woff2?|png|jpg|svg|ico)(\\?|$)/.test(url)) return response;
384
318
  var clone = response.clone();
@@ -425,10 +359,9 @@ export const INTERCEPTOR_SCRIPT = `(function() {
425
359
  var ct = xhr.getResponseHeader('content-type') || '';
426
360
  var url = xhr.__unbrowse_url || '';
427
361
  var isJs = ct.indexOf('javascript') !== -1 || /\\.js(\\?|$)/.test(url);
428
- var isData = ct.indexOf('json') !== -1 || ct.indexOf('application/x-protobuf') !== -1 ||
429
- ct.indexOf('text/plain') !== -1 ||
430
- url.indexOf('batchexecute') !== -1 || url.indexOf('/api/') !== -1 ||
431
- url.indexOf('graphql') !== -1 || url.indexOf('voyager') !== -1;
362
+ var isData = ct.indexOf('application/json') !== -1 || ct.indexOf('+json') !== -1 ||
363
+ ct.indexOf('application/x-protobuf') !== -1 || ct.indexOf('text/plain') !== -1 ||
364
+ url.indexOf('batchexecute') !== -1 || url.indexOf('/api/') !== -1;
432
365
  if (!isJs && !isData) return;
433
366
  if (/\\.(css|woff2?|png|jpg|svg|ico)(\\?|$)/.test(url)) return;
434
367
  var respBody = xhr.responseText || '';
@@ -454,7 +387,7 @@ export const INTERCEPTOR_SCRIPT = `(function() {
454
387
  /**
455
388
  * Collect intercepted requests from the page.
456
389
  */
457
- export async function collectInterceptedRequests(tabId: string): Promise<Array<{
390
+ async function collectInterceptedRequests(tabId: string): Promise<Array<{
458
391
  url: string;
459
392
  method: string;
460
393
  request_headers: Record<string, string>;
@@ -475,135 +408,6 @@ export async function collectInterceptedRequests(tabId: string): Promise<Array<{
475
408
  return [];
476
409
  }
477
410
 
478
- /**
479
- * Inject the interceptor script in chunks to work around kuri's ~1KB evaluate limit.
480
- * Falls back to scriptInject for persistent injection on new navigations.
481
- */
482
- export async function injectInterceptor(tabId: string): Promise<void> {
483
- // Split into setup (globals + fetch) and XHR parts
484
- const SETUP = `(function(){if(window.__unbrowse_interceptor_installed)return;window.__unbrowse_interceptor_installed=true;window.__unbrowse_intercepted=[];window.__UB_MAX=2*1024*1024;window.__UB_MAX_JS=2*1024*1024;window.__UB_MAX_N=500;})()`;
485
-
486
- const FETCH_PATCH = `(function(){if(!window.__unbrowse_interceptor_installed)return;var M=window.__UB_MAX,MJ=window.__UB_MAX_JS,MN=window.__UB_MAX_N;var oF=window.fetch;window.fetch=function(){var a=arguments,u=typeof a[0]==='string'?a[0]:(a[0]&&a[0].url?a[0].url:''),o=a[1]||{},m=(o.method||'GET').toUpperCase(),rb=o.body?String(o.body).substring(0,M):void 0,rh={};if(o.headers){if(typeof o.headers.forEach==='function')o.headers.forEach(function(v,k){rh[k]=v});else Object.keys(o.headers).forEach(function(k){rh[k]=o.headers[k]})}return oF.apply(this,a).then(function(r){if(window.__unbrowse_intercepted.length>=MN)return r;var ct=r.headers.get('content-type')||'';var isJ=ct.indexOf('javascript')!==-1||/\\.js(\\?|$)/.test(u);var isD=ct.indexOf('json')!==-1||ct.indexOf('x-protobuf')!==-1||ct.indexOf('text/plain')!==-1||u.indexOf('/api/')!==-1||u.indexOf('graphql')!==-1||u.indexOf('voyager')!==-1;if(!isJ&&!isD)return r;if(/\\.(css|woff2?|png|jpg|svg|ico)(\\?|$)/.test(u))return r;var c=r.clone();c.text().then(function(b){var lim=isJ?MJ:M;if(b.length>lim)return;var rr={};r.headers.forEach(function(v,k){rr[k]=v});window.__unbrowse_intercepted.push({url:u,method:m,request_headers:rh,request_body:rb,response_status:r.status,response_headers:rr,response_body:b,content_type:ct,is_js:isJ,timestamp:new Date().toISOString()})}).catch(function(){});return r}).catch(function(e){throw e})}})()`;
487
-
488
- const XHR_PATCH = `(function(){if(!window.__unbrowse_interceptor_installed)return;var M=window.__UB_MAX,MJ=window.__UB_MAX_JS,MN=window.__UB_MAX_N;var oO=XMLHttpRequest.prototype.open,oS=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open=function(m,u){this.__ub_m=m;this.__ub_u=u;this.__ub_h={};var oSH=this.setRequestHeader.bind(this);this.setRequestHeader=function(k,v){this.__ub_h[k]=v;oSH(k,v)}.bind(this);return oO.apply(this,arguments)};XMLHttpRequest.prototype.send=function(b){var x=this;x.addEventListener('load',function(){if(window.__unbrowse_intercepted.length>=MN)return;var ct=x.getResponseHeader('content-type')||'',u=x.__ub_u||'';var isJ=ct.indexOf('javascript')!==-1||/\\.js(\\?|$)/.test(u);var isD=ct.indexOf('json')!==-1||ct.indexOf('x-protobuf')!==-1||ct.indexOf('text/plain')!==-1||u.indexOf('/api/')!==-1||u.indexOf('graphql')!==-1||u.indexOf('voyager')!==-1;if(!isJ&&!isD)return;if(/\\.(css|woff2?|png|jpg|svg|ico)(\\?|$)/.test(u))return;var rb=x.responseText||'';var lim=isJ?MJ:M;if(rb.length>lim)return;window.__unbrowse_intercepted.push({url:u,method:(x.__ub_m||'GET').toUpperCase(),request_headers:x.__ub_h||{},request_body:b?String(b).substring(0,M):void 0,response_status:x.status,response_headers:{},response_body:rb,content_type:ct,is_js:isJ,timestamp:new Date().toISOString()})});return oS.apply(this,arguments)}})()`;
489
-
490
- // Inject in 3 small chunks
491
- for (const chunk of [SETUP, FETCH_PATCH, XHR_PATCH]) {
492
- await kuri.evaluate(tabId, chunk).catch(() => {});
493
- }
494
- }
495
-
496
-
497
- /**
498
- * Merge three passive capture data sources into a unified RawRequest list.
499
- * Priority: JS interceptor (has bodies) > HAR entries > extension observer > responseBodies-only.
500
- * Deduplicates by URL, keeps the highest-priority version.
501
- */
502
- function mergePassiveCaptureData(
503
- intercepted: Array<{ url: string; method: string; response_body?: string; response_status: number; request_headers: Record<string, string>; response_headers: Record<string, string>; request_body?: string; content_type?: string; is_js?: boolean; timestamp: string }>,
504
- harEntries: kuri.KuriHarEntry[],
505
- extensionEntries: ExtensionEntry[],
506
- responseBodies: Map<string, string>,
507
- ): RawRequest[] {
508
- const seen = new Map<string, RawRequest>();
509
-
510
- // Priority 1: JS-intercepted entries (have response bodies)
511
- for (const entry of intercepted) {
512
- if (entry.is_js) continue; // skip JS bundles
513
- seen.set(entry.url, {
514
- url: entry.url,
515
- method: entry.method,
516
- request_headers: entry.request_headers,
517
- request_body: entry.request_body,
518
- response_status: entry.response_status,
519
- response_headers: entry.response_headers,
520
- response_body: entry.response_body,
521
- timestamp: entry.timestamp,
522
- });
523
- }
524
-
525
- // Priority 2: HAR entries (supplement with responseBodies map)
526
- // Skip OPTIONS (CORS preflight) — they pollute method attribution
527
- for (const entry of harEntries) {
528
- const url = entry.request?.url;
529
- if (!url || seen.has(url)) continue;
530
- if (entry.request.method === "OPTIONS") continue;
531
- const reqHeaders: Record<string, string> = {};
532
- for (const h of entry.request.headers ?? []) reqHeaders[h.name] = h.value;
533
- const respHeaders: Record<string, string> = {};
534
- for (const h of entry.response.headers ?? []) respHeaders[h.name] = h.value;
535
- seen.set(url, {
536
- url,
537
- method: entry.request.method,
538
- request_headers: reqHeaders,
539
- request_body: entry.request.postData?.text,
540
- response_status: entry.response.status,
541
- response_headers: respHeaders,
542
- response_body: responseBodies.get(url) ?? entry.response.content?.text,
543
- timestamp: entry.startedDateTime,
544
- });
545
- }
546
-
547
- // Priority 3: Extension entries (URL+headers supplement, no bodies)
548
- for (const entry of extensionEntries) {
549
- if (seen.has(entry.url)) continue;
550
- const reqHeaders: Record<string, string> = {};
551
- for (const h of entry.requestHeaders ?? []) reqHeaders[h.name] = h.value;
552
- const respHeaders: Record<string, string> = {};
553
- for (const h of entry.responseHeaders ?? []) respHeaders[h.name] = h.value;
554
- seen.set(entry.url, {
555
- url: entry.url,
556
- method: entry.method,
557
- request_headers: reqHeaders,
558
- response_status: entry.statusCode ?? 0,
559
- response_headers: respHeaders,
560
- response_body: responseBodies.get(entry.url),
561
- timestamp: new Date(entry.timestamp).toISOString(),
562
- });
563
- }
564
-
565
- // Priority 4: responseBodies-only entries (from Performance API replay / HAR replay)
566
- // These URLs have bodies but weren't in HAR, interceptor, or extension data
567
- for (const [bodyUrl, body] of responseBodies) {
568
- if (seen.has(bodyUrl)) continue;
569
- seen.set(bodyUrl, {
570
- url: bodyUrl,
571
- method: "GET",
572
- request_headers: {},
573
- response_status: 200,
574
- response_headers: {},
575
- response_body: body,
576
- timestamp: new Date().toISOString(),
577
- });
578
- }
579
-
580
- return Array.from(seen.values());
581
- }
582
- /**
583
- * Collect network requests observed by kuri's builtin extension (chrome.webRequest).
584
- * Gracefully returns [] if the extension relay is not yet wired.
585
- */
586
- async function collectExtensionRequests(tabId: string): Promise<ExtensionEntry[]> {
587
- try {
588
- // Query the builtin extension's network log via the agent bridge
589
- const raw = await kuri.evaluate(tabId, `
590
- (function() {
591
- if (!window.__kuri || !window.__kuri._networkLog) return '[]';
592
- var log = window.__kuri._networkLog;
593
- window.__kuri._networkLog = []; // drain
594
- return JSON.stringify(log);
595
- })()
596
- `);
597
- if (typeof raw !== "string" || !raw.startsWith("[")) return [];
598
- const entries: ExtensionEntry[] = JSON.parse(raw);
599
- log("capture", `extension observer: ${entries.length} entries collected`);
600
- return entries;
601
- } catch {
602
- log("capture", "extension observer: not available (expected if relay not wired)");
603
- return [];
604
- }
605
- }
606
-
607
411
  /**
608
412
  * Poll document.readyState until "complete" or timeout.
609
413
  * Replaces page.waitForLoadState("networkidle").
@@ -814,12 +618,6 @@ export async function captureSession(
814
618
  ): Promise<CaptureResult> {
815
619
  await acquireTabSlot();
816
620
 
817
- // Guard: check browser access config before launching
818
- if (!isBrowserAccessAvailable()) {
819
- releaseTabSlot("no-tab");
820
- throw new Error("Browser access is not available (proxy-only config)");
821
- }
822
-
823
621
  // Ensure Kuri is running and tabs are discovered
824
622
  await kuri.start();
825
623
  await kuri.discoverTabs(); // Sync Chrome tabs into Kuri's registry
@@ -841,112 +639,54 @@ export async function captureSession(
841
639
  let captureTimedOut = false;
842
640
  let retryFreshTab = false;
843
641
  let captureError: unknown;
844
- const abortController = new AbortController();
845
- const { signal } = abortController;
846
- // phase() races each CDP call against the overall capture timeout (bug #113 fix)
847
- const phase = <T>(label: string, fn: () => Promise<T>) =>
848
- withBrowserPhaseTimeout(signal, label, fn);
849
- const timeoutHandle = setTimeout(() => {
642
+ const timeoutHandle = setTimeout(async () => {
850
643
  captureTimedOut = true;
851
- abortController.abort();
852
- resetTab(tabId).catch(() => {});
644
+ await resetTab(tabId);
853
645
  }, CAPTURE_TIMEOUT_MS);
854
646
 
855
647
  try {
856
648
  // Set headers: client hints + auth headers
857
649
  const allHeaders = { ...CLIENT_HINT_HEADERS, ...(authHeaders ?? {}) };
858
- await phase("setHeaders", () => kuri.setHeaders(tabId, allHeaders));
650
+ await kuri.setHeaders(tabId, allHeaders);
859
651
 
860
- // Navigate to origin first so cookies are set in the correct domain context
861
- // (CDP setCookie on about:blank doesn't bind to the target domain properly)
652
+ // Inject cookies
862
653
  if (cookies && cookies.length > 0) {
863
- const origin = new URL(url).origin;
864
- await phase("navigate:origin", () => kuri.navigate(tabId, origin));
865
- await phase("waitForLoad:origin", () => kuri.waitForLoad(tabId, 10_000).catch(() => {}));
866
-
867
- // Check if browser already has a valid session (not redirected to login).
868
- // If so, skip vault cookie injection — browser cookies are fresher and
869
- // injecting stale vault cookies (e.g. JSESSIONID) causes HTTP 400.
870
- const postOriginUrl = await phase("checkOriginUrl", () => kuri.getCurrentUrl(tabId));
871
- const LOGIN_PATHS_RE = /\/(login|signin|sign-in|sso|auth|uas\/login|checkpoint|oauth)/i;
872
- const originHost = new URL(origin).hostname;
873
- let browserAlreadyAuthed = false;
874
- try {
875
- const postHost = new URL(postOriginUrl).hostname;
876
- browserAlreadyAuthed = postHost === originHost && !LOGIN_PATHS_RE.test(new URL(postOriginUrl).pathname);
877
- } catch { /* bad URL — inject cookies as fallback */ }
878
-
879
- if (browserAlreadyAuthed) {
880
- log("capture", `browser already authenticated at ${postOriginUrl} — skipping vault cookie injection`);
881
- } else {
882
- await phase("injectCookies", () => injectCookies(tabId, cookies!));
883
- }
884
- }
885
-
886
- // Inject interceptor persistently — survives navigations via Page.addScriptToEvaluateOnNewDocument
887
- if (!interceptorInjectedTabs.has(tabId)) {
888
- try {
889
- await phase("scriptInject", () => kuri.scriptInject(tabId, INTERCEPTOR_SCRIPT));
890
- interceptorInjectedTabs.add(tabId);
891
- log("capture", "interceptor installed via scriptInject (persistent)");
892
- } catch {
893
- // Fallback for older kuri versions without scriptInject
894
- log("capture", "scriptInject unavailable — falling back to evaluate injection");
895
- }
654
+ await injectCookies(tabId, cookies);
896
655
  }
897
656
 
898
657
  // Start HAR recording
899
- await phase("harStart", () => kuri.harStart(tabId));
658
+ await kuri.harStart(tabId);
900
659
 
901
660
  // Determine page domain for JS bundle filtering
902
661
  let pageDomain: string | undefined;
903
662
  try { pageDomain = getRegistrableDomain(new URL(url).hostname); } catch { /* bad url */ }
904
663
 
664
+ // Inject fetch/XHR interceptor BEFORE navigation to capture all response bodies
665
+ // Navigate directly to target URL — skip origin pre-navigation to save 1-2s on heavy SPAs.
666
+ // The interceptor is re-injected after navigation anyway (page context resets on navigate).
667
+ await kuri.evaluate(tabId, INTERCEPTOR_SCRIPT).catch(() => {});
668
+
905
669
  // Navigate to target URL
906
- await phase("navigate", () => kuri.navigate(tabId, url));
670
+ await kuri.navigate(tabId, url);
907
671
 
908
- // If scriptInject wasn't available, fall back to single evaluate injection after navigation
909
- if (!interceptorInjectedTabs.has(tabId)) {
910
- await phase("evaluate:interceptor", () => kuri.evaluate(tabId, INTERCEPTOR_SCRIPT).catch(() => {}));
911
- log("capture", "interceptor re-injected via evaluate fallback");
912
- }
672
+ // Re-inject interceptor after navigation (page context resets on navigate)
673
+ try {
674
+ await new Promise((r) => setTimeout(r, 300));
675
+ await kuri.evaluate(tabId, INTERCEPTOR_SCRIPT);
676
+ } catch { /* page may not be ready */ }
913
677
 
914
678
  // Build response bodies map from intercepted requests
915
679
  const responseBodies = new Map<string, string>();
916
680
  const jsBundleBodies = new Map<string, string>();
917
681
  const MAX_JS_BUNDLES = 20;
918
682
 
919
- // Helper: drain intercepted data into responseBodies/jsBundleBodies
920
- const drainIntercepted = async () => {
921
- try {
922
- const entries = await collectInterceptedRequests(tabId);
923
- for (const entry of entries) {
924
- if (entry.is_js && jsBundleBodies.size < MAX_JS_BUNDLES && pageDomain) {
925
- try {
926
- const jsDomain = getRegistrableDomain(new URL(entry.url).hostname);
927
- if (jsDomain === pageDomain && entry.response_body) {
928
- jsBundleBodies.set(entry.url, entry.response_body);
929
- }
930
- } catch { /* bad url */ }
931
- } else if (entry.response_body && !entry.is_js) {
932
- responseBodies.set(entry.url, entry.response_body);
933
- }
934
- }
935
- return entries;
936
- } catch { return []; }
937
- };
938
-
939
683
  // Adaptive wait: handle Cloudflare challenges + SPA content loading + intent-aware API wait
940
- await phase("waitForContentReady", () => waitForContentReady(tabId, url, intent, responseBodies));
684
+ await waitForContentReady(tabId, url, intent, responseBodies);
941
685
 
942
- // Incremental collection: drain intercepted data after content ready
943
- await drainIntercepted();
944
-
945
- // Collect all intercepted requests (final sweep)
946
- const intercepted = await phase("collectIntercepted", () => collectInterceptedRequests(tabId));
947
- const extensionEntries = await phase("collectExtension", () => collectExtensionRequests(tabId));
686
+ // Collect all intercepted requests
687
+ const intercepted = await collectInterceptedRequests(tabId);
948
688
 
949
- // Separate JS bundles from data responses (from final sweep)
689
+ // Separate JS bundles from data responses
950
690
  for (const entry of intercepted) {
951
691
  if (entry.is_js && jsBundleBodies.size < MAX_JS_BUNDLES && pageDomain) {
952
692
  try {
@@ -960,107 +700,97 @@ export async function captureSession(
960
700
  }
961
701
  }
962
702
 
963
- // --- Performance API discovery + sync XHR replay ---
964
- // The JS interceptor can miss early requests (SPA API calls fire before injection).
965
- // HAR captures URLs but not response bodies.
966
- // Use Performance API to discover all fetch/XHR URLs, then replay-fetch via sync
967
- // XHR to get response bodies. This runs in-page so cookies/CORS are preserved.
968
- const REPLAY_SKIP = /\.(js|css|woff2?|png|jpe?g|gif|svg|ico|webp|avif|map|ttf)(\?|$)/i;
703
+ // Also collect via Performance API for requests the interceptor might have missed
704
+ // (requests that started before the interceptor was injected)
969
705
  try {
970
- const perfRaw = await phase("evaluate:perf", () => kuri.evaluate(tabId, `JSON.stringify(
706
+ const perfResult = await kuri.evaluate(tabId, `JSON.stringify(
971
707
  performance.getEntriesByType('resource')
972
708
  .filter(function(e) { return e.initiatorType === 'fetch' || e.initiatorType === 'xmlhttprequest'; })
973
- .map(function(e) { return e.name; })
974
- )`));
975
- if (typeof perfRaw === "string" && perfRaw.startsWith("[")) {
976
- const perfUrls: string[] = JSON.parse(perfRaw);
977
- log("capture", `Performance API found ${perfUrls.length} fetch/XHR URLs`);
978
- let replayCount = 0;
979
- for (const perfUrl of perfUrls) {
980
- if (responseBodies.has(perfUrl)) continue;
981
- if (REPLAY_SKIP.test(perfUrl)) continue;
982
- if (replayCount >= 30) break;
983
- try {
984
- const body = await phase("replay-fetch", () =>
985
- kuri.evaluate(tabId, `(function(){var x=new XMLHttpRequest();x.open('GET','${perfUrl.replace(/'/g, "\\'")}',false);x.send();return x.status>=200&&x.status<400?x.responseText:''})()`)
986
- );
987
- if (typeof body === "string" && body.length > 0 && body.length < 512 * 1024) {
988
- responseBodies.set(perfUrl, body);
989
- replayCount++;
990
- log("capture", `replay-fetched ${perfUrl.substring(0, 80)} (${body.length}B)`);
991
- }
992
- } catch { /* non-fatal */ }
993
- }
994
- if (replayCount > 0) {
995
- log("capture", `replay-fetched ${replayCount} API response bodies via Performance API`);
996
- }
997
- }
709
+ .map(function(e) { return { url: e.name, duration: e.duration }; })
710
+ )`);
711
+ // Performance API only gives us URLs, not bodies — but useful for request tracking
998
712
  } catch { /* non-fatal */ }
999
713
 
1000
714
  // Stop HAR recording and merge with intercepted data
1001
715
  let harEntries: kuri.KuriHarEntry[] = [];
1002
716
  try {
1003
- const harResult = await phase("harStop", () => kuri.harStop(tabId));
717
+ const harResult = await kuri.harStop(tabId);
1004
718
  harEntries = harResult.entries;
1005
719
  } catch { /* HAR may not be available */ }
1006
720
 
1007
- // --- HAR-based replay ---
1008
- // CDP HAR sees ALL network requests (even ones the JS interceptor missed).
1009
- // For any API-like HAR entry without a body in responseBodies, replay-fetch it.
1010
- const HAR_REPLAY_CT = /application\/json|text\/plain|\+json/i;
1011
- let harReplayCount = 0;
1012
- for (const entry of harEntries) {
1013
- const harUrl = entry.request?.url;
1014
- if (!harUrl || responseBodies.has(harUrl)) continue;
1015
- if (REPLAY_SKIP.test(harUrl)) continue;
1016
- const method = entry.request?.method?.toUpperCase();
1017
- if (method === "OPTIONS" || method === "HEAD") continue;
1018
- const status = entry.response?.status ?? 0;
1019
- if (status < 200 || status >= 400) continue;
1020
- const ct = (entry.response?.headers ?? []).find((h: { name: string }) => h.name.toLowerCase() === "content-type")?.value ?? "";
1021
- if (!HAR_REPLAY_CT.test(ct) && !harUrl.includes("/api/") && !harUrl.includes("_search") && !harUrl.includes("graphql")) continue;
1022
- if (harReplayCount >= 20) break;
1023
- try {
1024
- const postData = method !== "GET" ? entry.request?.postData?.text : undefined;
1025
- const replayScript = method === "GET" || !postData
1026
- ? `(function(){var x=new XMLHttpRequest();x.open('GET','${harUrl.replace(/'/g, "\\'")}',false);x.send();return x.status>=200&&x.status<400?x.responseText:''})()`
1027
- : `(function(){var x=new XMLHttpRequest();x.open('${method}','${harUrl.replace(/'/g, "\\'")}',false);x.setRequestHeader('Content-Type','application/json');x.send(${JSON.stringify(postData)});return x.status>=200&&x.status<400?x.responseText:''})()`;
1028
- const body = await phase("har-replay", () => kuri.evaluate(tabId, replayScript));
1029
- if (typeof body === "string" && body.length > 0 && body.length < 512 * 1024) {
1030
- responseBodies.set(harUrl, body);
1031
- harReplayCount++;
1032
- log("capture", `har-replay-fetched ${harUrl.substring(0, 80)} (${body.length}B)`);
1033
- }
1034
- } catch { /* non-fatal */ }
1035
- }
1036
- if (harReplayCount > 0) {
1037
- log("capture", `har-replay-fetched ${harReplayCount} API response bodies from HAR entries`);
1038
- }
1039
-
1040
721
  const har_lineage_id = nanoid();
1041
722
 
1042
723
  // Debug: log captured counts
1043
- log("capture", `tracked ${harEntries.length} HAR, ${intercepted.length} intercepted, ${extensionEntries.length} extension, ${responseBodies.size} bodies`);
724
+ log("capture", `tracked ${harEntries.length} HAR entries, ${intercepted.length} intercepted, ${responseBodies.size} response bodies`);
1044
725
  for (const [bodyUrl] of responseBodies) {
1045
726
  log("capture", `response body captured: ${bodyUrl.substring(0, 150)}`);
1046
727
  }
1047
728
 
729
+
1048
730
  let final_url = url;
1049
731
  let html: string | undefined;
1050
732
  try {
1051
- const rawUrl = await phase("getCurrentUrl", () => kuri.getCurrentUrl(tabId));
733
+ const rawUrl = await kuri.getCurrentUrl(tabId);
1052
734
  final_url = typeof rawUrl === "string" ? rawUrl : String(rawUrl ?? url);
1053
735
  // Validate it's actually a URL, fall back to original if not
1054
736
  try { new URL(final_url); } catch { final_url = url; }
1055
- html = await phase("getPageHtml", () => kuri.getPageHtml(tabId));
737
+ html = await kuri.getPageHtml(tabId);
1056
738
  } catch {}
1057
739
 
1058
- // Merge all passive capture sources into unified request list
1059
- const requests: RawRequest[] = mergePassiveCaptureData(intercepted, harEntries, extensionEntries, responseBodies);
1060
- log("capture", `tracked ${harEntries.length} HAR, ${intercepted.length} intercepted, ${extensionEntries.length} extension, ${responseBodies.size} bodies → ${requests.length} merged`);
740
+ // Build requests from HAR entries
741
+ const requests: RawRequest[] = harEntries.map((entry) => {
742
+ const reqHeaders: Record<string, string> = {};
743
+ for (const h of entry.request.headers) reqHeaders[h.name] = h.value;
744
+ const respHeaders: Record<string, string> = {};
745
+ for (const h of entry.response.headers) respHeaders[h.name] = h.value;
746
+ return {
747
+ url: entry.request.url,
748
+ method: entry.request.method,
749
+ request_headers: reqHeaders,
750
+ request_body: entry.request.postData?.text,
751
+ response_status: entry.response.status,
752
+ response_headers: respHeaders,
753
+ response_body: responseBodies.get(entry.request.url) ?? entry.response.content?.text,
754
+ timestamp: entry.startedDateTime,
755
+ };
756
+ });
757
+
758
+ // Synthesize RawRequests for intercepted responses not in HAR
759
+ const harUrls = new Set(harEntries.map((e) => e.request.url));
760
+ for (const entry of intercepted) {
761
+ if (entry.is_js) continue;
762
+ if (!harUrls.has(entry.url)) {
763
+ requests.push({
764
+ url: entry.url,
765
+ method: entry.method,
766
+ request_headers: entry.request_headers,
767
+ request_body: entry.request_body,
768
+ response_status: entry.response_status,
769
+ response_headers: entry.response_headers,
770
+ response_body: entry.response_body,
771
+ timestamp: entry.timestamp,
772
+ });
773
+ }
774
+ }
775
+
776
+ // Synthesize RawRequests for response bodies captured during intent-aware wait
777
+ const allTrackedUrls = new Set([...harUrls, ...intercepted.map((e) => e.url)]);
778
+ for (const [bodyUrl, body] of responseBodies) {
779
+ if (!allTrackedUrls.has(bodyUrl)) {
780
+ requests.push({
781
+ url: bodyUrl,
782
+ method: "GET",
783
+ request_headers: {},
784
+ response_status: 200,
785
+ response_headers: {},
786
+ response_body: body,
787
+ timestamp: new Date().toISOString(),
788
+ });
789
+ }
790
+ }
1061
791
 
1062
792
  // Extract session cookies via document.cookie
1063
- const rawCookies = await phase("extractCookies", () => extractCookiesFromPage(tabId, url));
793
+ const rawCookies = await extractCookiesFromPage(tabId, url);
1064
794
  const sessionCookies = filterFirstPartySessionCookies(rawCookies, url, final_url);
1065
795
 
1066
796
  if (captureTimedOut) throw new Error(`captureSession timed out after ${CAPTURE_TIMEOUT_MS}ms for ${url}`);
@@ -1105,7 +835,6 @@ export async function captureSession(
1105
835
  }
1106
836
  } finally {
1107
837
  clearTimeout(timeoutHandle);
1108
- abortController.abort(); // no-op if already aborted; prevents stale phase rejections
1109
838
  await resetTab(tabId);
1110
839
  releaseTabSlot(tabId);
1111
840
  }
@@ -1124,9 +853,6 @@ export async function executeInBrowser(
1124
853
  authHeaders?: Record<string, string>,
1125
854
  cookies?: Array<{ name: string; value: string; domain: string; path?: string; secure?: boolean; httpOnly?: boolean; sameSite?: string; expires?: number }>
1126
855
  ): Promise<{ status: number; data: unknown; trace_id: string }> {
1127
- if (!isBrowserAccessAvailable()) {
1128
- throw new Error("Browser access is not available (proxy-only config)");
1129
- }
1130
856
  await kuri.start();
1131
857
  await kuri.discoverTabs();
1132
858
 
@@ -1272,55 +998,6 @@ export interface BrowserActionStep {
1272
998
  timeoutMs?: number;
1273
999
  }
1274
1000
 
1275
- /** Internal record of when each action step started, used for request provenance tagging. */
1276
- interface StepTimingEntry {
1277
- stepIndex: number;
1278
- action: string;
1279
- ref?: string;
1280
- startedAt: string; // ISO timestamp
1281
- }
1282
-
1283
- /**
1284
- * Tag captured requests with the action step that triggered them.
1285
- * Uses timestamp comparison: a request is attributed to the latest step
1286
- * whose startedAt is <= the request's timestamp. Requests that arrived
1287
- * before any step (e.g. during initial navigation) are left untagged.
1288
- *
1289
- * Exported for testability.
1290
- */
1291
- export function tagRequestProvenance(
1292
- requests: RawRequest[],
1293
- stepTimings: StepTimingEntry[],
1294
- ): void {
1295
- if (stepTimings.length === 0) return;
1296
-
1297
- // Sort step timings by startedAt ascending (should already be, but be safe)
1298
- const sorted = [...stepTimings].sort(
1299
- (a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime(),
1300
- );
1301
-
1302
- for (const req of requests) {
1303
- if (!req.timestamp) continue;
1304
- const reqTime = new Date(req.timestamp).getTime();
1305
-
1306
- // Find the latest step that started at or before this request's timestamp
1307
- let matchedStep: StepTimingEntry | undefined;
1308
- for (const step of sorted) {
1309
- if (new Date(step.startedAt).getTime() <= reqTime) {
1310
- matchedStep = step;
1311
- } else {
1312
- break;
1313
- }
1314
- }
1315
-
1316
- if (matchedStep) {
1317
- req.triggered_by_step = matchedStep.stepIndex;
1318
- req.triggered_by_action = matchedStep.action;
1319
- req.triggered_by_ref = matchedStep.ref;
1320
- }
1321
- }
1322
- }
1323
-
1324
1001
  /** Result of a first-pass browser action execution. */
1325
1002
  export interface BrowserActionResult {
1326
1003
  /** Whether the action sequence completed without errors. */
@@ -1370,7 +1047,7 @@ export async function executeActionSequence(
1370
1047
 
1371
1048
  const traceId = nanoid();
1372
1049
  const stepResults: BrowserActionResult["steps"] = [];
1373
- const stepTimings: StepTimingEntry[] = [];
1050
+
1374
1051
  try {
1375
1052
  // Setup: headers + cookies
1376
1053
  const allHeaders = { ...CLIENT_HINT_HEADERS, ...(options?.authHeaders ?? {}) };
@@ -1395,17 +1072,10 @@ export async function executeActionSequence(
1395
1072
  }
1396
1073
 
1397
1074
  // Execute each action step
1398
- for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) {
1399
- const step = steps[stepIdx];
1400
- // Record step start time for request provenance tagging (#214)
1401
- stepTimings.push({
1402
- stepIndex: stepIdx,
1403
- action: step.action,
1404
- ref: step.ref,
1405
- startedAt: new Date().toISOString(),
1406
- });
1075
+ for (const step of steps) {
1407
1076
  try {
1408
1077
  let result: unknown;
1078
+
1409
1079
  switch (step.action) {
1410
1080
  case "snapshot":
1411
1081
  result = await kuri.snapshot(tabId, step.value);
@@ -1475,10 +1145,8 @@ export async function executeActionSequence(
1475
1145
  }
1476
1146
 
1477
1147
  // Collect final state
1478
- const rawFinalUrl = await kuri.getCurrentUrl(tabId).catch(() => url);
1479
- const finalUrl = typeof rawFinalUrl === "string" && rawFinalUrl.startsWith("http") ? rawFinalUrl : url;
1480
- const rawHtml = await kuri.getPageHtml(tabId).catch(() => undefined);
1481
- const html = typeof rawHtml === "string" && rawHtml.trimStart().startsWith("<") ? rawHtml : undefined;
1148
+ const finalUrl = String(await kuri.getCurrentUrl(tabId).catch(() => url));
1149
+ const html = await kuri.getPageHtml(tabId).catch(() => undefined);
1482
1150
  const lastSnapshot = await kuri.snapshot(tabId).catch(() => undefined);
1483
1151
 
1484
1152
  // Collect passive capture in the background
@@ -1497,9 +1165,9 @@ export async function executeActionSequence(
1497
1165
 
1498
1166
  const requests: RawRequest[] = harResult.entries.map((entry) => {
1499
1167
  const reqHeaders: Record<string, string> = {};
1500
- for (const h of entry.request.headers ?? []) reqHeaders[h.name] = h.value;
1168
+ for (const h of entry.request.headers) reqHeaders[h.name] = h.value;
1501
1169
  const respHeaders: Record<string, string> = {};
1502
- for (const h of entry.response.headers ?? []) respHeaders[h.name] = h.value;
1170
+ for (const h of entry.response.headers) respHeaders[h.name] = h.value;
1503
1171
  return {
1504
1172
  url: entry.request.url,
1505
1173
  method: entry.request.method,
@@ -1528,9 +1196,6 @@ export async function executeActionSequence(
1528
1196
  });
1529
1197
  }
1530
1198
 
1531
- // Tag each request with the action step that triggered it (#214)
1532
- tagRequestProvenance(requests, stepTimings);
1533
-
1534
1199
  const sessionCookies = filterFirstPartySessionCookies(
1535
1200
  await extractCookiesFromPage(tabId, url),
1536
1201
  url,