@trusty-squire/mcp 1.1.13-rc.9 → 1.1.13

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 (162) hide show
  1. package/README.md +136 -64
  2. package/assets/login/vnc-input.js +9 -0
  3. package/assets/login/vnc.html +214 -202
  4. package/assets/screencaps/connect-walkthrough.png +0 -0
  5. package/assets/screencaps/connect-walkthrough.svg +1 -1
  6. package/dist/api-client.d.ts +26 -3
  7. package/dist/api-client.d.ts.map +1 -1
  8. package/dist/api-client.js +19 -31
  9. package/dist/api-client.js.map +1 -1
  10. package/dist/bin.js +2 -3
  11. package/dist/bin.js.map +1 -1
  12. package/dist/bot/browser.d.ts +85 -80
  13. package/dist/bot/browser.d.ts.map +1 -1
  14. package/dist/bot/browser.js +1314 -1570
  15. package/dist/bot/browser.js.map +1 -1
  16. package/dist/bot/compact-observation-v2.d.ts +167 -0
  17. package/dist/bot/compact-observation-v2.d.ts.map +1 -0
  18. package/dist/bot/compact-observation-v2.js +1038 -0
  19. package/dist/bot/compact-observation-v2.js.map +1 -0
  20. package/dist/bot/credential-shape.d.ts +2 -0
  21. package/dist/bot/credential-shape.d.ts.map +1 -1
  22. package/dist/bot/credential-shape.js +21 -8
  23. package/dist/bot/credential-shape.js.map +1 -1
  24. package/dist/bot/element-fingerprint.d.ts +9 -0
  25. package/dist/bot/element-fingerprint.d.ts.map +1 -0
  26. package/dist/bot/element-fingerprint.js +147 -0
  27. package/dist/bot/element-fingerprint.js.map +1 -0
  28. package/dist/bot/email-verification.d.ts +7 -1
  29. package/dist/bot/email-verification.d.ts.map +1 -1
  30. package/dist/bot/email-verification.js +48 -14
  31. package/dist/bot/email-verification.js.map +1 -1
  32. package/dist/bot/google-login.d.ts +28 -52
  33. package/dist/bot/google-login.d.ts.map +1 -1
  34. package/dist/bot/google-login.js +246 -1119
  35. package/dist/bot/google-login.js.map +1 -1
  36. package/dist/bot/install-completion.d.ts +1 -2
  37. package/dist/bot/install-completion.d.ts.map +1 -1
  38. package/dist/bot/install-completion.js +1 -2
  39. package/dist/bot/install-completion.js.map +1 -1
  40. package/dist/bot/login-state.d.ts +13 -7
  41. package/dist/bot/login-state.d.ts.map +1 -1
  42. package/dist/bot/login-state.js +50 -114
  43. package/dist/bot/login-state.js.map +1 -1
  44. package/dist/bot/oauth-providers.d.ts +1 -1
  45. package/dist/bot/oauth-providers.d.ts.map +1 -1
  46. package/dist/bot/oauth-providers.js +3 -3
  47. package/dist/bot/oauth-providers.js.map +1 -1
  48. package/dist/bot/oauth-scope.d.ts +4 -0
  49. package/dist/bot/oauth-scope.d.ts.map +1 -0
  50. package/dist/bot/oauth-scope.js +58 -0
  51. package/dist/bot/oauth-scope.js.map +1 -0
  52. package/dist/bot/operator-browser-watchdog.d.ts +18 -6
  53. package/dist/bot/operator-browser-watchdog.d.ts.map +1 -1
  54. package/dist/bot/operator-browser-watchdog.js +91 -37
  55. package/dist/bot/operator-browser-watchdog.js.map +1 -1
  56. package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
  57. package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
  58. package/dist/bot/owner-process-reaper-worker.js +17 -0
  59. package/dist/bot/owner-process-reaper-worker.js.map +1 -0
  60. package/dist/bot/owner-process-reaper.d.ts +103 -0
  61. package/dist/bot/owner-process-reaper.d.ts.map +1 -0
  62. package/dist/bot/owner-process-reaper.js +1058 -0
  63. package/dist/bot/owner-process-reaper.js.map +1 -0
  64. package/dist/bot/pay-operator.d.ts +7 -1
  65. package/dist/bot/pay-operator.d.ts.map +1 -1
  66. package/dist/bot/pay-operator.js +269 -103
  67. package/dist/bot/pay-operator.js.map +1 -1
  68. package/dist/bot/profile.d.ts +10 -1
  69. package/dist/bot/profile.d.ts.map +1 -1
  70. package/dist/bot/profile.js +137 -11
  71. package/dist/bot/profile.js.map +1 -1
  72. package/dist/bot/promote-to-skill.js +1 -1
  73. package/dist/bot/promote-to-skill.js.map +1 -1
  74. package/dist/bot/provision-session.d.ts +57 -186
  75. package/dist/bot/provision-session.d.ts.map +1 -1
  76. package/dist/bot/provision-session.js +1976 -1812
  77. package/dist/bot/provision-session.js.map +1 -1
  78. package/dist/bot/remote-login-display.d.ts +61 -0
  79. package/dist/bot/remote-login-display.d.ts.map +1 -0
  80. package/dist/bot/remote-login-display.js +765 -0
  81. package/dist/bot/remote-login-display.js.map +1 -0
  82. package/dist/bot/session/hosts.d.ts +8 -0
  83. package/dist/bot/session/hosts.d.ts.map +1 -0
  84. package/dist/bot/session/hosts.js +36 -0
  85. package/dist/bot/session/hosts.js.map +1 -0
  86. package/dist/bot/session/lifecycle.d.ts +56 -0
  87. package/dist/bot/session/lifecycle.d.ts.map +1 -0
  88. package/dist/bot/session/lifecycle.js +962 -0
  89. package/dist/bot/session/lifecycle.js.map +1 -0
  90. package/dist/bot/session/model.d.ts +175 -0
  91. package/dist/bot/session/model.d.ts.map +1 -0
  92. package/dist/bot/session/model.js +77 -0
  93. package/dist/bot/session/model.js.map +1 -0
  94. package/dist/install/cli.d.ts +29 -9
  95. package/dist/install/cli.d.ts.map +1 -1
  96. package/dist/install/cli.js +243 -322
  97. package/dist/install/cli.js.map +1 -1
  98. package/dist/install/interactive.d.ts +1 -0
  99. package/dist/install/interactive.d.ts.map +1 -1
  100. package/dist/install/interactive.js +19 -5
  101. package/dist/install/interactive.js.map +1 -1
  102. package/dist/install/ui.d.ts.map +1 -1
  103. package/dist/install/ui.js +3 -15
  104. package/dist/install/ui.js.map +1 -1
  105. package/dist/operator-env.js +1 -1
  106. package/dist/operator-env.js.map +1 -1
  107. package/dist/server-instance-registry.d.ts +103 -0
  108. package/dist/server-instance-registry.d.ts.map +1 -0
  109. package/dist/server-instance-registry.js +392 -0
  110. package/dist/server-instance-registry.js.map +1 -0
  111. package/dist/server.d.ts +15 -1
  112. package/dist/server.d.ts.map +1 -1
  113. package/dist/server.js +164 -62
  114. package/dist/server.js.map +1 -1
  115. package/dist/session-guard.d.ts +28 -0
  116. package/dist/session-guard.d.ts.map +1 -0
  117. package/dist/session-guard.js +84 -0
  118. package/dist/session-guard.js.map +1 -0
  119. package/dist/session.d.ts +57 -13
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +246 -127
  122. package/dist/session.js.map +1 -1
  123. package/dist/tools/audit-log.d.ts +16 -2
  124. package/dist/tools/audit-log.d.ts.map +1 -1
  125. package/dist/tools/audit-log.js +223 -13
  126. package/dist/tools/audit-log.js.map +1 -1
  127. package/dist/tools/audit-rollup.d.ts +80 -0
  128. package/dist/tools/audit-rollup.d.ts.map +1 -0
  129. package/dist/tools/audit-rollup.js +0 -0
  130. package/dist/tools/audit-rollup.js.map +1 -0
  131. package/dist/tools/fetch-credential.d.ts +37 -0
  132. package/dist/tools/fetch-credential.d.ts.map +1 -0
  133. package/dist/tools/fetch-credential.js +153 -0
  134. package/dist/tools/fetch-credential.js.map +1 -0
  135. package/dist/tools/index.d.ts +3 -1
  136. package/dist/tools/index.d.ts.map +1 -1
  137. package/dist/tools/index.js +7 -3
  138. package/dist/tools/index.js.map +1 -1
  139. package/dist/tools/operate-pay.d.ts +4 -4
  140. package/dist/tools/operate-pay.d.ts.map +1 -1
  141. package/dist/tools/operate-pay.js +151 -96
  142. package/dist/tools/operate-pay.js.map +1 -1
  143. package/dist/tools/provision-drive.d.ts +47 -39
  144. package/dist/tools/provision-drive.d.ts.map +1 -1
  145. package/dist/tools/provision-drive.js +268 -164
  146. package/dist/tools/provision-drive.js.map +1 -1
  147. package/dist/tools/use-credential.d.ts.map +1 -1
  148. package/dist/tools/use-credential.js +3 -2
  149. package/dist/tools/use-credential.js.map +1 -1
  150. package/package.json +4 -6
  151. package/dist/bot/operator-direct-identity.d.ts +0 -26
  152. package/dist/bot/operator-direct-identity.d.ts.map +0 -1
  153. package/dist/bot/operator-direct-identity.js +0 -120
  154. package/dist/bot/operator-direct-identity.js.map +0 -1
  155. package/dist/bot/operator-profile-pool.d.ts +0 -97
  156. package/dist/bot/operator-profile-pool.d.ts.map +0 -1
  157. package/dist/bot/operator-profile-pool.js +0 -855
  158. package/dist/bot/operator-profile-pool.js.map +0 -1
  159. package/dist/bot/xvfb.d.ts +0 -13
  160. package/dist/bot/xvfb.d.ts.map +0 -1
  161. package/dist/bot/xvfb.js +0 -146
  162. package/dist/bot/xvfb.js.map +0 -1
@@ -22,22 +22,83 @@
22
22
  // captchas still need the click-and-wait pattern (the Tier 2 captcha
23
23
  // gate).
24
24
  import { chromium as baseChromium } from "playwright";
25
- import sharp from "sharp";
26
25
  import { createRequire } from "node:module";
27
- import { randomUUID } from "node:crypto";
28
- import { Socket, createServer } from "node:net";
26
+ import { Socket } from "node:net";
29
27
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
30
- import { execFileSync, spawn } from "node:child_process";
28
+ import { readFile } from "node:fs/promises";
29
+ import { join } from "node:path";
30
+ import { spawn } from "node:child_process";
31
31
  import { isSameRecipeDomain } from "@trusty-squire/recipe-schema";
32
- import { acquireFreeProfileOperationGuard, CHROME_PROFILE_DIR, clearStaleSingletonLock, closeProfileWithProof, currentProfileHolderPid, launchWithProfileGate, processBirthIdentity, processBirthIdentityState, profilePathIdentity, profileProcessIdentity, profileProcessIdentityState, profileProcessMatches, PROFILE_BUSY_MESSAGE, ProfileBusyError, reapProfileHolderIfOwned, signalProfileProcess, waitForProfileFree, } from "./profile.js";
33
- import { GOOGLE_LOGIN_COOKIE_MARKERS, } from "./operator-profile-pool.js";
34
- import { startXvfb, xvfbAvailable } from "./xvfb.js";
35
- import { createOperatorBrowserMarker, OPERATOR_BROWSER_MARKER_ENV, operatorBrowserProcessMatchesMarker, startGlobalOperatorBrowserProcessWatchdog, } from "./operator-browser-watchdog.js";
32
+ import { clearStaleSingletonLock, closeProfileWithProof, currentProfileHolderPid, processBirthIdentity, processBirthIdentityState, profileProcessIdentity, profileProcessIdentityState, profileProcessMatches, PROFILE_BUSY_MESSAGE, ProfileBusyError, reapProfileHolderIfOwned, signalProfileProcess, } from "./profile.js";
33
+ import { createOperatorBrowserMarker, OPERATOR_BROWSER_MARKER_ENV, operatorBrowserProcessMarker, operatorBrowserProcessMatchesMarker, startGlobalOperatorBrowserProcessWatchdog, } from "./operator-browser-watchdog.js";
34
+ import { bindOwnerBrowserLaunch, markOwnerBrowserLaunchTerminal, reconcileOwnerBrowserLaunchAfterLeaderExit, terminateOwnerBrowserLaunch, trackOwnerBrowserLaunch, trackOwnerProcess, untrackOwnerBrowserLaunch, untrackOwnerProcess, } from "./owner-process-reaper.js";
36
35
  // Lazy registration: installing the plugin mutates the chromium singleton
37
36
  // from playwright-extra so we only do it once per process. We require()
38
37
  // the CJS modules lazily (the stealth toolchain only ships CJS) and treat
39
38
  // stealth as best-effort — a missing dep should never crash the bot.
40
39
  const require = createRequire(import.meta.url);
40
+ // Operator signup runs are deliberately headed. Google, Stytch, and Cloudflare
41
+ // routinely reject a headless Chrome even when it is otherwise self-launched.
42
+ const OPERATOR_BROWSER_HEADLESS = false;
43
+ export function registerLocalBrowserLaunch(profileDir, baseEnv = process.env, marker = createOperatorBrowserMarker()) {
44
+ trackOwnerBrowserLaunch(marker, profileDir);
45
+ return {
46
+ marker,
47
+ env: { ...baseEnv, [OPERATOR_BROWSER_MARKER_ENV]: marker },
48
+ };
49
+ }
50
+ export async function closeBrowserContextWithin(context, timeoutMs = 2_000) {
51
+ let timer;
52
+ const outcome = await Promise.race([
53
+ Promise.resolve()
54
+ .then(() => context.close())
55
+ .then(() => true, () => false),
56
+ new Promise((resolveTimeout) => {
57
+ timer = setTimeout(() => resolveTimeout(false), timeoutMs);
58
+ }),
59
+ ]);
60
+ if (timer !== undefined)
61
+ clearTimeout(timer);
62
+ return outcome;
63
+ }
64
+ function spawnLocalBrowser(binary, args, profileDir, options) {
65
+ const ownership = registerLocalBrowserLaunch(profileDir, options.env, options.marker);
66
+ try {
67
+ const child = spawn(binary, [...args], {
68
+ env: ownership.env,
69
+ stdio: options.stdio,
70
+ detached: options.detached,
71
+ });
72
+ localBrowserLaunchMarkers.set(child, ownership.marker);
73
+ child.once("exit", () => {
74
+ setTimeout(() => {
75
+ reconcileOwnerBrowserLaunchAfterLeaderExit(ownership.marker, profileDir);
76
+ }, 0).unref();
77
+ });
78
+ return child;
79
+ }
80
+ catch (error) {
81
+ untrackOwnerBrowserLaunch(ownership.marker);
82
+ throw error;
83
+ }
84
+ }
85
+ const localBrowserLaunchMarkers = new WeakMap();
86
+ function markLocalBrowserLaunchTerminal(child) {
87
+ if (child === null)
88
+ return;
89
+ const marker = localBrowserLaunchMarkers.get(child);
90
+ if (marker !== undefined)
91
+ markOwnerBrowserLaunchTerminal(marker);
92
+ }
93
+ export async function closeLocalBrowserLaunch(marker, profileDir, runtime = {}) {
94
+ if (marker === undefined)
95
+ return;
96
+ (runtime.markTerminal ?? markOwnerBrowserLaunchTerminal)(marker);
97
+ if (!(await (runtime.terminate ?? terminateOwnerBrowserLaunch)(marker, profileDir))) {
98
+ throw new Error("local login browser closure unproven");
99
+ }
100
+ (runtime.untrack ?? untrackOwnerBrowserLaunch)(marker);
101
+ }
41
102
  export function contextInitScriptsFor(options) {
42
103
  if (options.hardened)
43
104
  return [];
@@ -236,12 +297,16 @@ export async function runCaptureConfirmedPaymentSubmit(options) {
236
297
  catch (error) {
237
298
  clickError = error;
238
299
  }
300
+ if (clickError instanceof BrowserClickDispatchError &&
301
+ clickError.dispatchStatus === "not_dispatched") {
302
+ await options.clear();
303
+ throw clickError;
304
+ }
239
305
  const evidence = await options.readEvidence();
240
306
  await options.clear();
241
307
  if (!evidence.dispatched) {
242
308
  if (clickError !== undefined && !inputDispatchPossible)
243
309
  throw clickError;
244
- options.onSubmitDispatched?.();
245
310
  throw new PaymentSubmitOutcomeUnknownError();
246
311
  }
247
312
  options.onSubmitDispatched?.();
@@ -329,17 +394,6 @@ function checkoutOutcomeBaselineFromDispatchSnapshot(snapshot) {
329
394
  terminalUrlIdentity: identities?.terminal ?? null,
330
395
  };
331
396
  }
332
- function parseCheckoutOutcomeDispatchSnapshot(value) {
333
- if (value === null || typeof value !== "object")
334
- return null;
335
- const snapshot = value;
336
- if (typeof snapshot.url !== "string" ||
337
- !Array.isArray(snapshot.urls) ||
338
- !snapshot.urls.every((url) => typeof url === "string")) {
339
- return null;
340
- }
341
- return { url: snapshot.url, urls: snapshot.urls };
342
- }
343
397
  function checkoutUrlOrderIdentities(rawUrl) {
344
398
  try {
345
399
  const url = new URL(rawUrl);
@@ -451,6 +505,22 @@ function checkoutUrlOrderIdentities(rawUrl) {
451
505
  return null;
452
506
  }
453
507
  }
508
+ // Shopify's thank-you route keeps the checkout token in a nested path
509
+ // (`/checkouts/cn/<token>/<locale>/thank-you`), so it has no terminal order
510
+ // identity for checkoutUrlOrderIdentities to compare. Treat it as terminal
511
+ // only when the post-submit page also exposes unambiguous confirmation copy.
512
+ function isShopifyCheckoutThankYouRoute(rawUrl) {
513
+ try {
514
+ const segments = new URL(rawUrl).pathname
515
+ .split("/")
516
+ .filter(Boolean)
517
+ .map((segment) => decodeURIComponent(segment).toLowerCase().replace(/-/g, "_"));
518
+ return ((segments[0] === "checkout" || segments[0] === "checkouts") && segments.at(-1) === "thank_you");
519
+ }
520
+ catch {
521
+ return false;
522
+ }
523
+ }
454
524
  // EbisuMart — a widely-deployed Japanese EC platform (the Hibiya Kadan
455
525
  // checkout runs on it) — names its card fields CREDIT_NO / CREDIT_NAME /
456
526
  // SECURITY_CD / CREDIT_LIMIT_MONTH / CREDIT_LIMIT_YEAR. PAN name/id and JP
@@ -569,36 +639,6 @@ const CHECKOUT_CARD_VALUE_FIELD_SELECTORS = [
569
639
  CHECKOUT_CVV_FIELD_SELECTORS,
570
640
  CHECKOUT_CARD_NAME_FIELD_SELECTORS,
571
641
  ].join(",");
572
- // Defense-in-depth redaction set for operate_screenshot. The capture-scoped
573
- // guard refuses nonempty secrets before pixels are read; these selectors also
574
- // identify empty sensitive controls and provide rectangles that are composited
575
- // over the captured bytes. The set mirrors the card fields
576
- // fillCheckoutCardIntoFrames writes into, plus payment/type_secret markers and
577
- // other secret-shaped inputs.
578
- const SCREENSHOT_SECRET_FIELD_SELECTORS = [
579
- '[data-ts-sealed-payment="1"]',
580
- 'input[type="password" i]',
581
- 'input[autocomplete~="one-time-code" i]',
582
- 'input[name*="otp" i]',
583
- 'input[id*="otp" i]',
584
- 'input[name*="pin" i]',
585
- 'input[id*="pin" i]',
586
- ].join(",");
587
- const SCREENSHOT_REDACTION_SELECTORS = `${CHECKOUT_CARD_VALUE_FIELD_SELECTORS},${SCREENSHOT_SECRET_FIELD_SELECTORS}`;
588
- function sealedElementSemanticKeys(descriptor) {
589
- const clean = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
590
- const tag = clean(descriptor.tag);
591
- const type = clean(descriptor.type);
592
- const landmark = clean(descriptor.landmark);
593
- const label = clean(descriptor.labelText ?? descriptor.ariaLabel ?? descriptor.placeholder);
594
- return Array.from(new Set([
595
- clean(descriptor.testId) ? `test:${clean(descriptor.testId)}` : "",
596
- clean(descriptor.id) ? `id:${clean(descriptor.id)}` : "",
597
- clean(descriptor.name) ? `name:${tag}:${type}:${clean(descriptor.name)}` : "",
598
- label ? `label:${landmark}:${tag}:${type}:${label}` : "",
599
- `position:${landmark}:${tag}:${type}:${descriptor.ordinal}`,
600
- ].filter((key) => key.length > 0)));
601
- }
602
642
  // Charge-verb button labels — the click that may move money. Used by
603
643
  // submitFilledCheckout to find the charge control, and by operate_act's
604
644
  // pending-card-fill guard to recognize and cap caller-placed attempts while a
@@ -612,6 +652,112 @@ function sealedElementSemanticKeys(descriptor) {
612
652
  // where a bare noun like 購入 would otherwise swallow navigation labels such
613
653
  // as 購入手続きへ). 確定 (finalize) is deliberate — 確認 (review) must NOT match.
614
654
  export const CHECKOUT_SUBMIT_LABEL_RE = /^(?:pay(?:\s+now)?|place\s+order|complete\s+(?:order|purchase|payment)|submit\s+payment|buy\s+now|confirm\s+(?:order|payment))\b|^ご?注文(?:内容)?[をの]?確定|^ご?注文する|^確定(?:する|$)|^購入(?:する|を確定|$)|^今すぐ(?:購入|注文|支払)|^支払う|^お?支払い(?:を確定|$)/i;
655
+ const CHECKOUT_PAYMENT_EXECUTION_PATHS = new Set([
656
+ "authorize",
657
+ "capture",
658
+ "charge",
659
+ "charges",
660
+ "completecheckout",
661
+ "completeorder",
662
+ "confirmpayment",
663
+ "orders",
664
+ "placeorder",
665
+ "purchase",
666
+ ]);
667
+ const CHECKOUT_PAYMENT_EXECUTION_MUTATIONS = new Set([
668
+ "authorizepayment",
669
+ "capturepayment",
670
+ "completecheckout",
671
+ "completeorder",
672
+ "completepayment",
673
+ "confirmpayment",
674
+ "createcharge",
675
+ "placeorder",
676
+ "submitpayment",
677
+ ]);
678
+ const CHECKOUT_PAYMENT_EXCLUDED_PATH_SEGMENTS = new Set([
679
+ "analytics",
680
+ "collect",
681
+ "events",
682
+ "logs",
683
+ "metrics",
684
+ "paymentmethods",
685
+ "setupintents",
686
+ "telemetry",
687
+ "tokenization",
688
+ "tokens",
689
+ "tracking",
690
+ ]);
691
+ const CHECKOUT_PAYMENT_REQUEST_OBSERVATION_MS = 15_000;
692
+ function normalizedPaymentOperation(value) {
693
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
694
+ }
695
+ function isCheckoutPaymentExecutionOperation(value) {
696
+ return CHECKOUT_PAYMENT_EXECUTION_MUTATIONS.has(normalizedPaymentOperation(value));
697
+ }
698
+ function collectGraphqlMutationCandidates(query, candidates) {
699
+ const mutation = /\bmutation\b(?:\s+([A-Za-z_][A-Za-z0-9_-]*))?(?:\s*\([^{}]*\))?(?:\s+@[^{]+)?\s*\{\s*(?:([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*)?([A-Za-z_][A-Za-z0-9_-]*)/gi;
700
+ for (const match of query.matchAll(mutation)) {
701
+ if (match[1] !== undefined)
702
+ candidates.push(match[1]);
703
+ if (match[3] !== undefined)
704
+ candidates.push(match[3]);
705
+ }
706
+ }
707
+ function hasCheckoutPaymentExecutionPayload(request) {
708
+ const payload = request.postData();
709
+ if (payload === null)
710
+ return false;
711
+ const candidates = [];
712
+ try {
713
+ const parsed = JSON.parse(payload);
714
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object")
715
+ return false;
716
+ const record = parsed;
717
+ if (typeof record.operationName === "string")
718
+ candidates.push(record.operationName);
719
+ if (typeof record.query === "string")
720
+ collectGraphqlMutationCandidates(record.query, candidates);
721
+ }
722
+ catch {
723
+ const form = new URLSearchParams(payload);
724
+ const operationName = form.get("operationName");
725
+ if (operationName !== null)
726
+ candidates.push(operationName);
727
+ const query = form.get("query");
728
+ collectGraphqlMutationCandidates(query ?? payload, candidates);
729
+ }
730
+ return candidates.some(isCheckoutPaymentExecutionOperation);
731
+ }
732
+ function isCheckoutPaymentRequest(request) {
733
+ if (!["document", "fetch", "xhr"].includes(request.resourceType()))
734
+ return false;
735
+ const method = request.method().toUpperCase();
736
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS")
737
+ return false;
738
+ try {
739
+ const segments = new URL(request.url()).pathname.split("/").filter(Boolean);
740
+ const normalizedSegments = segments.map(normalizedPaymentOperation);
741
+ if (normalizedSegments.some((segment) => CHECKOUT_PAYMENT_EXCLUDED_PATH_SEGMENTS.has(segment))) {
742
+ return false;
743
+ }
744
+ const lastSegment = segments.at(-1);
745
+ if (lastSegment !== undefined &&
746
+ CHECKOUT_PAYMENT_EXECUTION_PATHS.has(normalizedPaymentOperation(lastSegment))) {
747
+ return true;
748
+ }
749
+ if (lastSegment !== undefined &&
750
+ (normalizedPaymentOperation(lastSegment) === "confirm" ||
751
+ normalizedPaymentOperation(lastSegment) === "capture") &&
752
+ segments.some((segment) => normalizedPaymentOperation(segment) === "paymentintents")) {
753
+ return true;
754
+ }
755
+ }
756
+ catch {
757
+ return false;
758
+ }
759
+ return hasCheckoutPaymentExecutionPayload(request);
760
+ }
615
761
  export function checkoutSubmitLabel(signals) {
616
762
  return (signals.ariaLabel || signals.inputValue || signals.textContent || "").trim();
617
763
  }
@@ -1737,19 +1883,13 @@ export function extractGoogleAccountEmail(pageText) {
1737
1883
  }
1738
1884
  // Map a cookie jar to the OAuth providers that have a LIVE logged-in session.
1739
1885
  // The auth cookies that mean "signed in": GitHub → `user_session`; Google →
1740
- // any of the *SID session cookies (NID / CONSENT / 1P_JAR are set even when
1741
- // logged out, so they are deliberately NOT signals). Host-scoped so a
1886
+ // a legacy *SID cookie. NID / CONSENT / 1P_JAR and the current account-chooser
1887
+ // family are set even when logged out, so they are deliberately NOT signals.
1888
+ // Host-scoped so a
1742
1889
  // google.com cookie can't pass for github. Cookie NAMES + presence only;
1743
1890
  // values are checked for non-triviality, never logged. Exported for tests.
1744
1891
  export function sessionProvidersFromCookies(cookies) {
1745
- const SIGNATURES = [
1746
- { provider: "github", host: /(^|\.)github\.com$/i, names: ["user_session"] },
1747
- {
1748
- provider: "google",
1749
- host: /(^|\.)google\.com$/i,
1750
- names: GOOGLE_LOGIN_COOKIE_MARKERS,
1751
- },
1752
- ];
1892
+ const SIGNATURES = [{ provider: "github", host: /(^|\.)github\.com$/i, names: ["user_session"] }];
1753
1893
  const live = [];
1754
1894
  for (const sig of SIGNATURES) {
1755
1895
  const present = cookies.some((c) => sig.host.test(c.domain.replace(/^\./, "")) &&
@@ -1758,6 +1898,11 @@ export function sessionProvidersFromCookies(cookies) {
1758
1898
  if (present)
1759
1899
  live.push(sig.provider);
1760
1900
  }
1901
+ const googleSession = cookies.some((cookie) => /(^|\.)google\.com$/i.test(cookie.domain.replace(/^\./, "")) &&
1902
+ ["__Secure-1PSID", "SID", "HSID", "SSID", "APISID", "SAPISID"].includes(cookie.name) &&
1903
+ cookie.value.length > 10);
1904
+ if (googleSession)
1905
+ live.push("google");
1761
1906
  return live;
1762
1907
  }
1763
1908
  function isCaptchaVariant(v) {
@@ -1889,7 +2034,7 @@ export function resolveChannelBinary(channel) {
1889
2034
  // Cloudflare Turnstile's interactive challenge FAILS a Playwright/patchright
1890
2035
  // launchPersistentContext-driven Chrome and PASSES a Chrome the operator
1891
2036
  // launches itself and then attaches to over CDP — every other variable held
1892
- // constant (same box, same datacenter IP, same Xvfb display, same Chrome 148
2037
+ // constant (same box, same datacenter IP, same headed display, same Chrome 148
1893
2038
  // binary, same software-WebGL, same humanized click). The discriminator
1894
2039
  // matrix:
1895
2040
  // launchPersistentContext + CDP click → "Verification failed"
@@ -2001,41 +2146,36 @@ export async function launchCancellablePersistentContext(opts) {
2001
2146
  void lateCleanup;
2002
2147
  return { status: "cancelled", closeState: "unknown" };
2003
2148
  }
2004
- // Find an ephemeral TCP port for Chrome's --remote-debugging-port.
2005
- function findFreePort() {
2006
- return new Promise((resolve, reject) => {
2007
- const srv = createServer();
2008
- srv.on("error", reject);
2009
- srv.listen(0, "127.0.0.1", () => {
2010
- const addr = srv.address();
2011
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
2012
- srv.close(() => (port > 0 ? resolve(port) : reject(new Error("no port"))));
2013
- });
2014
- });
2015
- }
2016
- // Poll Chrome's DevTools HTTP endpoint until it answers (the browser is up
2017
- // and accepting CDP), or the deadline passes. Returns the base endpoint URL
2018
- // connectOverCDP accepts.
2019
- async function waitForDevtools(port, deadlineMs, child) {
2020
- const base = `http://127.0.0.1:${port}`;
2149
+ const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
2150
+ export async function waitForOwnedDevtoolsEndpoint(profileDir, deadlineMs, child) {
2151
+ const activePortPath = join(profileDir, DEVTOOLS_ACTIVE_PORT_FILE);
2021
2152
  const deadline = Date.now() + deadlineMs;
2022
2153
  let lastErr = "";
2023
2154
  while (Date.now() < deadline) {
2024
- if (child !== undefined && !childProcessIsRunning(child)) {
2025
- throw new Error("Chrome exited before its DevTools endpoint became available");
2155
+ if (!childProcessIsRunning(child)) {
2156
+ throw new Error("Chrome exited before its owned DevTools endpoint became available");
2026
2157
  }
2027
2158
  try {
2028
- const res = await fetch(`${base}/json/version`, { signal: AbortSignal.timeout(2_000) });
2029
- if (res.ok)
2030
- return base;
2031
- lastErr = `HTTP ${res.status}`;
2159
+ const [portText, browserPath] = (await readFile(activePortPath, "utf8")).split(/\r?\n/);
2160
+ const port = Number(portText);
2161
+ if (!Number.isInteger(port) ||
2162
+ port < 1 ||
2163
+ port > 65_535 ||
2164
+ browserPath === undefined ||
2165
+ !/^\/devtools\/browser\/[A-Za-z0-9-]+$/.test(browserPath)) {
2166
+ throw new Error("invalid DevToolsActivePort contents");
2167
+ }
2168
+ return `ws://127.0.0.1:${port}${browserPath}`;
2032
2169
  }
2033
- catch (err) {
2034
- lastErr = err instanceof Error ? err.message : String(err);
2170
+ catch (error) {
2171
+ lastErr = error instanceof Error ? error.message : String(error);
2035
2172
  }
2036
- await new Promise((r) => setTimeout(r, 200));
2173
+ await new Promise((resolveWait) => {
2174
+ const timer = setTimeout(resolveWait, 200);
2175
+ timer.unref();
2176
+ });
2037
2177
  }
2038
- throw new Error(`Chrome DevTools endpoint never came up on ${base} (${lastErr})`);
2178
+ throw new Error(`Owned Chrome DevTools endpoint was not published (${lastErr})`);
2039
2179
  }
2040
2180
  export async function withChromeStartupLock(fn, opts = {}) {
2041
2181
  const lockDir = opts.lockDir ?? "/tmp/trusty-squire-chrome-start.lock";
@@ -2077,11 +2217,10 @@ const selfManagedChromes = new Map();
2077
2217
  const ownedChromeProcessTrees = new Set();
2078
2218
  let selfManagedCleanupInstalled = false;
2079
2219
  let selfManagedTerminationSignalExitEnabled = true;
2080
- let orphanVerifierReapRan = false;
2081
- const orphanOperatorProfilesReaped = new Set();
2082
2220
  function cleanupSelfManagedChromes() {
2083
2221
  for (const proof of ownedChromeProcessTrees) {
2084
2222
  signalOwnedChromeProcessTree(proof.identity, proof.processGroup, "SIGKILL", { proof });
2223
+ untrackOwnerProcess(proof.identity);
2085
2224
  }
2086
2225
  selfManagedChromes.clear();
2087
2226
  }
@@ -2092,7 +2231,20 @@ const exitForSelfManagedSignal = (code) => {
2092
2231
  const onSelfManagedSigint = () => exitForSelfManagedSignal(2);
2093
2232
  const onSelfManagedSigterm = () => exitForSelfManagedSignal(15);
2094
2233
  const onSelfManagedSighup = () => exitForSelfManagedSignal(1);
2095
- // Whether the self-managed SIGINT/SIGTERM handlers may exit the process.
2234
+ const selfManagedTerminationSignalHandlers = [
2235
+ ["SIGHUP", onSelfManagedSighup],
2236
+ ["SIGINT", onSelfManagedSigint],
2237
+ ["SIGTERM", onSelfManagedSigterm],
2238
+ ];
2239
+ export function synchronizeSelfManagedChromeTerminationSignalHandlers(enabled, runtime = process) {
2240
+ for (const [signal, handler] of selfManagedTerminationSignalHandlers) {
2241
+ if (enabled)
2242
+ runtime.once(signal, handler);
2243
+ else
2244
+ runtime.removeListener(signal, handler);
2245
+ }
2246
+ }
2247
+ // Whether the self-managed termination-signal handlers may exit the process.
2096
2248
  // False means another shutdown owner (the MCP server's disconnect coordinator,
2097
2249
  // or an in-flight interactive login) holds process-exit responsibility.
2098
2250
  export function isSelfManagedChromeTerminationSignalExitEnabled() {
@@ -2104,14 +2256,7 @@ export function setSelfManagedChromeTerminationSignalExitEnabled(enabled) {
2104
2256
  selfManagedTerminationSignalExitEnabled = enabled;
2105
2257
  if (!selfManagedCleanupInstalled)
2106
2258
  return;
2107
- if (enabled) {
2108
- process.once("SIGINT", onSelfManagedSigint);
2109
- process.once("SIGTERM", onSelfManagedSigterm);
2110
- }
2111
- else {
2112
- process.removeListener("SIGINT", onSelfManagedSigint);
2113
- process.removeListener("SIGTERM", onSelfManagedSigterm);
2114
- }
2259
+ synchronizeSelfManagedChromeTerminationSignalHandlers(enabled);
2115
2260
  }
2116
2261
  function installSelfManagedChromeCleanup() {
2117
2262
  if (selfManagedCleanupInstalled)
@@ -2119,18 +2264,22 @@ function installSelfManagedChromeCleanup() {
2119
2264
  selfManagedCleanupInstalled = true;
2120
2265
  process.once("exit", cleanupSelfManagedChromes);
2121
2266
  if (selfManagedTerminationSignalExitEnabled) {
2122
- process.once("SIGINT", onSelfManagedSigint);
2123
- process.once("SIGTERM", onSelfManagedSigterm);
2267
+ synchronizeSelfManagedChromeTerminationSignalHandlers(true);
2124
2268
  }
2125
- process.once("SIGHUP", onSelfManagedSighup);
2126
2269
  }
2127
2270
  function registerSelfManagedChrome(child, profileDir, processGroup = false) {
2128
2271
  installSelfManagedChromeCleanup();
2129
2272
  const identity = child.pid === undefined ? null : profileProcessIdentity(child.pid, profileDir);
2130
2273
  if (identity !== null) {
2131
2274
  const proof = trackOwnedChromeProcessTree(identity, processGroup);
2132
- if (proof !== null)
2275
+ if (proof !== null) {
2276
+ const marker = proof.identity.process_marker;
2277
+ if (marker !== undefined && !bindOwnerBrowserLaunch(marker, proof.identity)) {
2278
+ releaseOwnedChromeProcessTree(proof);
2279
+ throw new Error("local browser launch identity could not be bound to owner custody");
2280
+ }
2133
2281
  selfManagedChromes.set(identity.pid, { identity, processGroup, proof });
2282
+ }
2134
2283
  }
2135
2284
  child.once("exit", () => {
2136
2285
  if (child.pid === undefined)
@@ -2249,16 +2398,20 @@ export function captureOwnedChromeProcessTreeProof(identity, processGroup, optio
2249
2398
  }
2250
2399
  function trackOwnedChromeProcessTree(identity, processGroup) {
2251
2400
  installSelfManagedChromeCleanup();
2252
- const proof = captureOwnedChromeProcessTreeProof(identity, processGroup);
2401
+ const marker = operatorBrowserProcessMarker(identity.pid);
2402
+ const trackedIdentity = marker === null ? identity : { ...identity, process_marker: marker };
2403
+ const proof = captureOwnedChromeProcessTreeProof(trackedIdentity, processGroup);
2253
2404
  if (proof === null)
2254
2405
  return null;
2255
2406
  ownedChromeProcessTrees.add(proof);
2407
+ trackOwnerProcess(proof.identity);
2256
2408
  return proof;
2257
2409
  }
2258
2410
  function releaseOwnedChromeProcessTree(proof) {
2259
2411
  if (proof === null)
2260
2412
  return;
2261
2413
  ownedChromeProcessTrees.delete(proof);
2414
+ untrackOwnerProcess(proof.identity);
2262
2415
  }
2263
2416
  export function ownedChromeProcessTreeState(proof, options = {}) {
2264
2417
  const memberState = options.memberState ?? processBirthIdentityState;
@@ -2373,97 +2526,6 @@ export async function terminateTrackedProfileChild(child, profileDir, options =
2373
2526
  }
2374
2527
  return identity;
2375
2528
  }
2376
- // Stale verifier/operator browsers are the expensive leak mode: if the MCP process dies
2377
- // mid-verify, self-launched Chrome survives as PPID=1 with a
2378
- // ~/.trusty-squire/profiles/verify-* or ~/.trusty-squire/chrome-profile
2379
- // user-data-dir. It keeps the profile lock,
2380
- // burns memory, and may leave defunct helper children. A live verifier should
2381
- // never be parented to init, so these are safe to reap at the next browser
2382
- // startup. Best-effort and Linux-only; failure must not block signups.
2383
- export function matchesReapableBrowserArgs(args, profileDirs, includeVerifier) {
2384
- return reapableBrowserProfile(args, profileDirs, includeVerifier) !== null;
2385
- }
2386
- function reapableBrowserProfile(args, profileDirs, includeVerifier) {
2387
- if (!/(?:chrome|chromium)/i.test(args))
2388
- return null;
2389
- const configuredProfiles = new Map(profileDirs.map((profileDir) => [profileDir, profilePathIdentity(profileDir)]));
2390
- const match = /--user-data-dir=(?:"([^"]+)"|'([^']+)'|([^\s]+))(?=\s|$)/.exec(args);
2391
- const candidate = match?.[1] ?? match?.[2] ?? match?.[3];
2392
- if (candidate !== undefined) {
2393
- const identity = profilePathIdentity(candidate);
2394
- if ([...configuredProfiles.values()].includes(identity))
2395
- return identity;
2396
- if (includeVerifier && /[/\\]\.trusty-squire[/\\]profiles[/\\]verify-[^/\\]+$/.test(identity)) {
2397
- return identity;
2398
- }
2399
- }
2400
- for (const [profileDir, identity] of configuredProfiles) {
2401
- for (const exactPath of new Set([profileDir, identity])) {
2402
- const variants = [exactPath, `"${exactPath}"`, `'${exactPath}'`];
2403
- if (variants.some((variant) => {
2404
- const option = `--user-data-dir=${variant}`;
2405
- const offset = args.indexOf(option);
2406
- return (offset >= 0 &&
2407
- /\s|$/.test(args.slice(offset + option.length, offset + option.length + 1)));
2408
- })) {
2409
- return identity;
2410
- }
2411
- }
2412
- }
2413
- return null;
2414
- }
2415
- export function claimOrphanBrowserReapScope(profileDir) {
2416
- const includeVerifier = !orphanVerifierReapRan;
2417
- const profileDirs = [...new Set([CHROME_PROFILE_DIR, profileDir])].filter((candidate) => !orphanOperatorProfilesReaped.has(candidate));
2418
- if (!includeVerifier && profileDirs.length === 0)
2419
- return null;
2420
- orphanVerifierReapRan = true;
2421
- for (const candidate of profileDirs)
2422
- orphanOperatorProfilesReaped.add(candidate);
2423
- return { includeVerifier, profileDirs };
2424
- }
2425
- function reapOrphanedBrowsersOnce(profileDir) {
2426
- if (process.platform !== "linux")
2427
- return;
2428
- const scope = claimOrphanBrowserReapScope(profileDir);
2429
- if (scope === null)
2430
- return;
2431
- let rows = "";
2432
- try {
2433
- rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], {
2434
- stdio: ["ignore", "pipe", "ignore"],
2435
- }).toString("utf8");
2436
- }
2437
- catch {
2438
- return;
2439
- }
2440
- const identities = [];
2441
- for (const line of rows.split("\n")) {
2442
- const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
2443
- if (match === null)
2444
- continue;
2445
- const pid = Number(match[1]);
2446
- const ppid = Number(match[2]);
2447
- const args = match[3] ?? "";
2448
- const expectedProfile = reapableBrowserProfile(args, scope.profileDirs, scope.includeVerifier);
2449
- if (Number.isFinite(pid) && ppid === 1 && expectedProfile !== null) {
2450
- const identity = profileProcessIdentity(pid, expectedProfile);
2451
- if (identity !== null)
2452
- identities.push(identity);
2453
- }
2454
- }
2455
- if (identities.length === 0)
2456
- return;
2457
- console.error(`[operator] reaping ${identities.length} orphaned Chrome process(es)`);
2458
- for (const identity of identities) {
2459
- signalProfileProcess(identity, identity.user_data_dir, "SIGTERM");
2460
- }
2461
- setTimeout(() => {
2462
- for (const identity of identities) {
2463
- signalProfileProcess(identity, identity.user_data_dir, "SIGKILL");
2464
- }
2465
- }, 2_000).unref();
2466
- }
2467
2529
  // Classify an anti-bot interstitial page from its (title + body) text.
2468
2530
  // `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
2469
2531
  // `verificationPassed` is the signal the challenge SUCCEEDED — but
@@ -2510,124 +2572,45 @@ export function stripCloudflareChallengeParams(rawUrl) {
2510
2572
  export function childProcessIsRunning(child) {
2511
2573
  return child !== null && child.exitCode === null && child.signalCode === null;
2512
2574
  }
2513
- export async function attachSelfManagedLoginContext(endpoint, child, profileDir, identity, options = {}) {
2514
- let browser = null;
2515
- try {
2516
- browser = await (options.launcher ?? getChromium()).connectOverCDP(endpoint);
2517
- const context = browser.contexts()[0];
2518
- if (context === undefined) {
2519
- throw new Error("self-launched login Chrome exposed no default browser context");
2520
- }
2521
- return { browser, context };
2522
- }
2523
- catch (error) {
2524
- await browser?.close().catch(() => undefined);
2525
- const terminateChild = options.terminateChild ??
2526
- ((ownedChild, ownedProfileDir, ownedIdentity) => terminateTrackedProfileChild(ownedChild, ownedProfileDir, {
2527
- identity: ownedIdentity,
2528
- }));
2529
- await terminateChild(child, profileDir, identity);
2530
- throw error;
2531
- }
2532
- }
2533
2575
  function profileCollisionFromStderr(stderr) {
2534
2576
  return /ProcessSingleton|SingletonLock|profile.*in use/i.test(stderr)
2535
2577
  ? new ProfileBusyError(PROFILE_BUSY_MESSAGE)
2536
2578
  : null;
2537
2579
  }
2538
- // Self-launch Chrome + connectOverCDP for the INTERACTIVE login (connect /
2539
- // `mcp login`), instead of Playwright's launchPersistentContext.
2540
- //
2541
- // This is the STATE.md 2026-06-12 finding — the same launcher tell that fails
2542
- // Cloudflare Turnstile from a launchPersistentContext-driven Chrome and passes
2543
- // from a self-launched one — ported to the login path. BrowserController (the
2544
- // signup path) already migrated; the connect login was the last consumer of
2545
- // the detectable launcher. Kept STANDALONE (not a BrowserController method) so
2546
- // the working signup path is untouched.
2580
+ // The signal that quits the plain login browser.
2547
2581
  //
2548
- // Persistent profile is preserved: --user-data-dir=profileDir means the
2549
- // provider session (Google/GitHub cookies) still lands in the bot's profile,
2550
- // which the connect flow needs to seed for later Gmail-reading / OAuth signups.
2551
- export async function launchSelfManagedLoginContext(params) {
2552
- let child = null;
2553
- let childIdentity = null;
2554
- const endpoint = await withChromeStartupLock(async () => {
2555
- const port = await findFreePort();
2556
- clearStaleSingletonLock(params.profileDir);
2557
- const argv = [
2558
- `--remote-debugging-port=${port}`,
2559
- "--remote-debugging-address=127.0.0.1",
2560
- `--user-data-dir=${params.profileDir}`,
2561
- "--no-first-run",
2562
- "--no-default-browser-check",
2563
- "--password-store=basic",
2564
- "--window-position=0,0",
2565
- `--window-size=${params.window.width},${params.window.height}`,
2566
- "--lang=en-US",
2567
- ...(params.extraArgs ?? []),
2568
- ...(params.proxyServer !== null ? [`--proxy-server=${params.proxyServer}`] : []),
2569
- // NB: we build argv ourselves, so Playwright's --enable-automation
2570
- // (and the rest of its launch instrumentation — the actual Turnstile
2571
- // tell) is never added. That is the whole point of self-launching.
2572
- params.appMode ? `--app=${params.initialUrl}` : params.initialUrl,
2573
- ];
2574
- const spawned = spawn(params.binary, argv, {
2575
- env: params.env,
2576
- stdio: ["ignore", "ignore", "pipe"],
2577
- });
2578
- child = spawned;
2579
- childIdentity = registerSelfManagedChrome(spawned, params.profileDir);
2580
- let chromeStderr = "";
2581
- spawned.stderr?.on("data", (chunk) => {
2582
- chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
2583
- });
2584
- try {
2585
- const endpoint = await waitForDevtools(port, 30_000, spawned);
2586
- childIdentity = await resolveAttachedProfileChildIdentity(spawned, params.profileDir, childIdentity);
2587
- if (process.platform === "linux" && childIdentity === null) {
2588
- throw new Error("self-launched login Chrome exited before identity was proven");
2589
- }
2590
- return endpoint;
2591
- }
2592
- catch (err) {
2593
- childIdentity = await terminateTrackedProfileChild(spawned, params.profileDir, {
2594
- identity: childIdentity,
2595
- });
2596
- const detail = chromeStderr.trim();
2597
- const collision = profileCollisionFromStderr(detail);
2598
- if (collision !== null)
2599
- throw collision;
2600
- throw new Error(`${err instanceof Error ? err.message : String(err)}` +
2601
- `${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
2602
- }
2603
- }, { deadlineMs: 0 });
2604
- if (child === null) {
2605
- throw new Error("self-launched login Chrome lost its process handle");
2606
- }
2607
- const { browser, context } = await attachSelfManagedLoginContext(endpoint, child, params.profileDir, childIdentity);
2608
- let torn = false;
2609
- const teardown = async () => {
2610
- if (torn)
2611
- return;
2612
- torn = true;
2613
- // Disconnect the CDP browser first; over connectOverCDP this leaves the
2614
- // real Chrome running, so kill the child explicitly (SIGTERM, then a
2615
- // hard SIGKILL after a short grace) to avoid the zombie-chrome leak.
2616
- await browser.close();
2617
- if (child !== null && childIdentity !== null) {
2618
- signalProfileProcess(childIdentity, params.profileDir, "SIGTERM");
2619
- }
2620
- };
2621
- const forceTeardown = () => {
2622
- if (childIdentity !== null) {
2623
- const tracked = selfManagedChromes.get(childIdentity.pid);
2624
- signalOwnedChromeProcessTree(childIdentity, false, "SIGKILL", {
2625
- ...(tracked === undefined ? {} : { proof: tracked.proof }),
2626
- });
2627
- }
2628
- reapProfileHolderIfOwned(params.profileDir, childIdentity);
2629
- };
2630
- return { context, teardown, forceTeardown, identity: childIdentity };
2582
+ // It MUST NOT be SIGTERM. Chrome routes SIGTERM to its "session ending" path,
2583
+ // which exits abruptly on the assumption the OS is tearing the machine down —
2584
+ // it does NOT flush the SQLite cookie store, and the store's own commit timer
2585
+ // is ~30s away. `connect` kills this browser within a couple of seconds of the
2586
+ // user finishing the Google OAuth dance, so a SIGTERM teardown discarded the
2587
+ // very session the ceremony existed to establish: the claim landed, the session
2588
+ // file was written, and the follow-up provider probe correctly reported "Google
2589
+ // not connected". SIGINT takes Chrome's graceful shutdown path, which flushes.
2590
+ // Measured on real Chrome 2026-09-04: cookie set 6s before the signal survives
2591
+ // SIGINT/SIGHUP and is lost on SIGTERM, deterministically, for both a bare pid
2592
+ // and a process-group signal.
2593
+ export const PLAIN_LOGIN_BROWSER_QUIT_SIGNAL = "SIGINT";
2594
+ // How long to let Chrome's graceful shutdown run before handing over to the
2595
+ // owner-launch reaper, whose own escalation starts at SIGTERM and would undo
2596
+ // the flush we just asked for.
2597
+ const PLAIN_LOGIN_BROWSER_QUIT_DEADLINE_MS = 10_000;
2598
+ // Quit the plain login browser and only THEN run the ownership-proving
2599
+ // teardown. Exported for tests: the ordering here is the fix, not an
2600
+ // implementation detail — `finalize` (the reaper) escalates SIGTERM →
2601
+ // SIGKILL, so running it while Chrome is still flushing reintroduces the
2602
+ // abrupt exit this signal choice exists to avoid.
2603
+ export async function quitPlainLoginBrowser(opts) {
2604
+ const pollMs = opts.pollMs ?? 25;
2605
+ const wait = opts.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
2606
+ // An undelivered quit has nothing to wait for — hand straight over to the
2607
+ // reaper rather than burning the grace window on a process we cannot signal.
2608
+ if (opts.signalQuit(PLAIN_LOGIN_BROWSER_QUIT_SIGNAL)) {
2609
+ const deadline = Date.now() + (opts.deadlineMs ?? PLAIN_LOGIN_BROWSER_QUIT_DEADLINE_MS);
2610
+ while (opts.isRunning() && Date.now() < deadline)
2611
+ await wait(pollMs);
2612
+ }
2613
+ await opts.finalize();
2631
2614
  }
2632
2615
  // Launch a TRULY PLAIN Chrome for the interactive connect claim — NO
2633
2616
  // `--remote-debugging-port`, NO `connectOverCDP`, NO Playwright attach at all.
@@ -2640,106 +2623,146 @@ export async function launchSelfManagedLoginContext(params) {
2640
2623
  // though the same CDP browser passes a DIRECT accounts.google.com sign-in. The
2641
2624
  // tell is the CDP attachment itself (NOT the launcher, NOT the flags, NOT
2642
2625
  // `navigator.webdriver` — all separately ruled out). The connect claim doesn't
2643
- // need to drive the browser: the USER signs in over noVNC, completion is read
2644
- // from the API (`installPoll`), and provider seeding is read from the profile's
2645
- // SQLite cookie store (`profileHasProviderCookies`). So we spawn Chrome and
2646
- // only ever kill it — never attach.
2626
+ // need to drive the browser: the USER signs in interactively, completion comes
2627
+ // from the API (`installPoll`) plus its explicit Finish callback. So we spawn
2628
+ // Chrome and only ever kill it — never attach.
2647
2629
  //
2648
2630
  // Persistent profile is preserved (--user-data-dir=profileDir) so the Google/
2649
2631
  // GitHub session still lands in the bot's profile for later signups.
2650
2632
  export async function launchPlainLoginBrowser(params) {
2651
2633
  let child = null;
2652
2634
  let childIdentity = null;
2653
- await withChromeStartupLock(async () => {
2654
- clearStaleSingletonLock(params.profileDir);
2655
- const argv = [
2656
- `--user-data-dir=${params.profileDir}`,
2657
- "--no-first-run",
2658
- "--no-default-browser-check",
2659
- "--password-store=basic",
2660
- "--window-position=0,0",
2661
- `--window-size=${params.window.width},${params.window.height}`,
2662
- "--lang=en-US",
2663
- ...(params.extraArgs ?? []),
2664
- ...(params.proxyServer !== null ? [`--proxy-server=${params.proxyServer}`] : []),
2665
- `--app=${params.url}`,
2666
- ];
2667
- const spawned = spawn(params.binary, argv, {
2668
- env: params.env,
2669
- stdio: ["ignore", "ignore", "pipe"],
2670
- });
2671
- child = spawned;
2672
- childIdentity = registerSelfManagedChrome(spawned, params.profileDir);
2673
- let chromeStderr = "";
2674
- spawned.stderr?.on("data", (chunk) => {
2675
- chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
2676
- });
2677
- // Give Chrome a moment to actually come up (or die). Unlike the CDP path
2678
- // there is no devtools endpoint to poll — but a crash-on-launch (bad
2679
- // profile, missing lib) should surface here, not 15min later as a blank
2680
- // noVNC. If the process is already dead, throw with its stderr.
2681
- await new Promise((r) => setTimeout(r, 1_200));
2682
- childIdentity ??=
2683
- spawned.pid === undefined ? null : profileProcessIdentity(spawned.pid, params.profileDir);
2684
- childIdentity = await resolveAttachedProfileChildIdentity(spawned, params.profileDir, childIdentity);
2685
- if (childIdentity !== null) {
2686
- const existing = selfManagedChromes.get(childIdentity.pid);
2687
- const proof = existing?.identity.start_time === childIdentity.start_time
2688
- ? existing.proof
2689
- : trackOwnedChromeProcessTree(childIdentity, false);
2690
- if (proof !== null) {
2691
- selfManagedChromes.set(childIdentity.pid, {
2692
- identity: childIdentity,
2693
- processGroup: false,
2694
- proof,
2695
- });
2635
+ // Login's plain browser is the no-CDP path used for Google sign-in. Reserve
2636
+ // its marker before launch so the rc.15 fail-closed reaper bind is legitimate.
2637
+ const ownership = registerLocalBrowserLaunch(params.profileDir, params.env);
2638
+ const launchMarker = ownership.marker;
2639
+ let spawned = false;
2640
+ try {
2641
+ await withChromeStartupLock(async () => {
2642
+ clearStaleSingletonLock(params.profileDir);
2643
+ const argv = [
2644
+ `--user-data-dir=${params.profileDir}`,
2645
+ "--no-first-run",
2646
+ "--no-default-browser-check",
2647
+ "--password-store=basic",
2648
+ "--window-position=0,0",
2649
+ `--window-size=${params.window.width},${params.window.height}`,
2650
+ "--lang=en-US",
2651
+ ...(params.extraArgs ?? []),
2652
+ ...(params.proxyServer !== null ? [`--proxy-server=${params.proxyServer}`] : []),
2653
+ `--app=${params.url}`,
2654
+ ];
2655
+ const launched = spawnLocalBrowser(params.binary, argv, params.profileDir, {
2656
+ detached: process.platform !== "win32",
2657
+ env: ownership.env,
2658
+ stdio: ["ignore", "ignore", "pipe"],
2659
+ marker: launchMarker,
2660
+ });
2661
+ child = launched;
2662
+ spawned = true;
2663
+ let chromeStderr = "";
2664
+ launched.stderr?.on("data", (chunk) => {
2665
+ chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
2666
+ });
2667
+ // Give Chrome a moment to actually come up (or die). Unlike the CDP path
2668
+ // there is no devtools endpoint to poll — but a crash-on-launch (bad
2669
+ // profile, missing lib) should surface here, not 15min later as a blank
2670
+ // browser. If the process is already dead, throw with its stderr.
2671
+ await new Promise((r) => setTimeout(r, 1_200));
2672
+ childIdentity ??=
2673
+ launched.pid === undefined
2674
+ ? null
2675
+ : profileProcessIdentity(launched.pid, params.profileDir);
2676
+ childIdentity = await resolveAttachedProfileChildIdentity(launched, params.profileDir, childIdentity);
2677
+ childIdentity = registerSelfManagedChrome(launched, params.profileDir) ?? childIdentity;
2678
+ if (childIdentity !== null) {
2679
+ const existing = selfManagedChromes.get(childIdentity.pid);
2680
+ const proof = existing?.identity.start_time === childIdentity.start_time
2681
+ ? existing.proof
2682
+ : trackOwnedChromeProcessTree(childIdentity, false);
2683
+ if (proof !== null) {
2684
+ selfManagedChromes.set(childIdentity.pid, {
2685
+ identity: childIdentity,
2686
+ processGroup: false,
2687
+ proof,
2688
+ });
2689
+ }
2690
+ // Bind the owner-launch anchor to the profile-proven child identity.
2691
+ // registerSelfManagedChrome only binds when it can read the marker back
2692
+ // from the process, but Chrome erases the marker from its own environ
2693
+ // when it rewrites process titles, so that bind is skipped here. Use
2694
+ // the marker we generated for THIS launch, keyed to the tracked launch
2695
+ // record, so teardown can trust this anchor and reap the marker-only
2696
+ // wrapper processes (the google-chrome launcher's stdout/stderr `cat`
2697
+ // relays and crashpad) instead of reporting closure unproven.
2698
+ bindOwnerBrowserLaunch(launchMarker, childIdentity);
2699
+ }
2700
+ if (!childProcessIsRunning(launched)) {
2701
+ reapProfileHolderIfOwned(params.profileDir, childIdentity);
2702
+ const detail = chromeStderr.trim();
2703
+ const collision = profileCollisionFromStderr(detail);
2704
+ if (collision !== null)
2705
+ throw collision;
2706
+ const termination = launched.exitCode !== null
2707
+ ? `code ${launched.exitCode}`
2708
+ : `signal ${launched.signalCode ?? "unknown"}`;
2709
+ throw new Error(`plain login Chrome exited immediately (${termination})` +
2710
+ `${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
2696
2711
  }
2697
- }
2698
- if (!childProcessIsRunning(spawned)) {
2699
- reapProfileHolderIfOwned(params.profileDir, childIdentity);
2700
- const detail = chromeStderr.trim();
2701
- const collision = profileCollisionFromStderr(detail);
2702
- if (collision !== null)
2703
- throw collision;
2704
- const termination = spawned.exitCode !== null
2705
- ? `code ${spawned.exitCode}`
2706
- : `signal ${spawned.signalCode ?? "unknown"}`;
2707
- throw new Error(`plain login Chrome exited immediately (${termination})` +
2708
- `${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
2709
- }
2710
- if (process.platform === "linux" && childIdentity === null) {
2711
- throw new Error("plain login Chrome identity could not be proven");
2712
- }
2713
- }, { deadlineMs: 0 });
2714
- let torn = false;
2712
+ if (process.platform === "linux" && childIdentity === null) {
2713
+ throw new Error("plain login Chrome identity could not be proven");
2714
+ }
2715
+ }, { deadlineMs: 0 });
2716
+ }
2717
+ catch (error) {
2718
+ if (!spawned)
2719
+ untrackOwnerBrowserLaunch(launchMarker);
2720
+ throw error;
2721
+ }
2722
+ let teardownPromise;
2715
2723
  const forceTeardown = () => {
2724
+ markLocalBrowserLaunchTerminal(child);
2716
2725
  if (childIdentity !== null) {
2717
2726
  const tracked = selfManagedChromes.get(childIdentity.pid);
2718
2727
  signalOwnedChromeProcessTree(childIdentity, false, "SIGKILL", {
2719
2728
  ...(tracked === undefined ? {} : { proof: tracked.proof }),
2720
2729
  });
2721
2730
  }
2731
+ else if (childProcessIsRunning(child)) {
2732
+ child?.kill("SIGKILL");
2733
+ }
2722
2734
  reapProfileHolderIfOwned(params.profileDir, childIdentity);
2723
2735
  };
2724
- const teardown = async () => {
2725
- if (torn)
2726
- return;
2727
- torn = true;
2728
- if (child !== null && childIdentity !== null) {
2729
- signalProfileProcess(childIdentity, params.profileDir, "SIGTERM");
2730
- }
2736
+ const teardown = () => {
2737
+ teardownPromise ??= (async () => {
2738
+ markLocalBrowserLaunchTerminal(child);
2739
+ await quitPlainLoginBrowser({
2740
+ signalQuit: (signal) => {
2741
+ if (child !== null && childIdentity !== null) {
2742
+ return signalProfileProcess(childIdentity, params.profileDir, signal);
2743
+ }
2744
+ if (childProcessIsRunning(child)) {
2745
+ child?.kill(signal);
2746
+ return true;
2747
+ }
2748
+ return false;
2749
+ },
2750
+ isRunning: () => childProcessIsRunning(child),
2751
+ finalize: async () => await closeLocalBrowserLaunch(launchMarker, params.profileDir),
2752
+ });
2753
+ })();
2754
+ return teardownPromise;
2731
2755
  };
2732
2756
  return {
2733
2757
  teardown,
2734
2758
  forceTeardown,
2735
2759
  isRunning: () => childProcessIsRunning(child),
2736
2760
  identity: childIdentity,
2761
+ marker: launchMarker,
2737
2762
  };
2738
2763
  }
2739
2764
  export class BrowserController {
2740
- // The persistent browser context. Persistent (launchPersistentContext)
2741
- // rather than an ephemeral context so the profile carries the user's
2742
- // Google session across runs — see profile.ts / google-login.ts.
2765
+ // A persistent browser context backed by the user's real Chrome profile.
2743
2766
  context = null;
2744
2767
  page = null;
2745
2768
  checkoutCardGroupScope;
@@ -2747,30 +2770,36 @@ export class BrowserController {
2747
2770
  paymentInstrumentExpectation;
2748
2771
  observedPaymentInstrumentMismatch;
2749
2772
  checkoutSubmitSequence = 0;
2750
- checkoutSubmitDispatchBindingName = `__trustySquirePaymentSubmitDispatch_${randomUUID().replaceAll("-", "")}`;
2751
- checkoutSubmitDispatchBindingPages = new WeakSet();
2752
- checkoutSubmitDispatchWaiters = new Map();
2753
2773
  clickDispatchSequence = 0;
2754
- sealedDocumentSequence = 0;
2755
- sealedDocuments = new Map();
2774
+ mainDocumentSequence = 0;
2775
+ mainDocumentIdentities = new WeakMap();
2776
+ trackedMainDocumentPages = new WeakSet();
2756
2777
  // The page start() configured with the controller's navigation/captcha
2757
- // handlers. OAuth may temporarily switch `this.page` to a popup, but warm
2778
+ // handlers. OAuth may temporarily switch `this.page` to a popup, but session
2758
2779
  // reuse must always restore this original page rather than adopting a popup
2759
2780
  // whose lifecycle handlers were never installed.
2760
2781
  primaryPage = null;
2782
+ // Tabs the PAGE opened (target=_blank / window.open) since the operator armed
2783
+ // adoption for an action, oldest first. A real user lands on the tab their
2784
+ // click opened — an email magic-link button in Gmail is the case this exists
2785
+ // for — so the operator has to follow it too. Reading the link's href instead
2786
+ // is not an option: a single-use login token is sealed and must never be
2787
+ // handed to the model as text.
2788
+ openedTabs = [];
2761
2789
  // Self-launch path (Turnstile-safe; see selfLaunchEnabled). When we spawn
2762
2790
  // Chrome ourselves and attach over CDP, these hold the child process and
2763
2791
  // the connected Browser so close() can tear both down.
2764
2792
  childChrome = null;
2765
2793
  childChromeIdentity = null;
2766
2794
  childChromeProcessGroup = false;
2795
+ ownedDisplayRig = null;
2767
2796
  ownedChromeProcessTreeProof = null;
2768
2797
  operatorProcessMarker = null;
2798
+ ownerLaunchTracked = false;
2769
2799
  cdpBrowser = null;
2770
2800
  // True once a local browser context launched this session.
2771
2801
  launchedContext = false;
2772
2802
  launchedProfileHolderIdentity = null;
2773
- profileOperationLease = null;
2774
2803
  startPromise = null;
2775
2804
  closePromise = null;
2776
2805
  startCancellationRequested = false;
@@ -2779,7 +2808,6 @@ export class BrowserController {
2779
2808
  persistentFallbackLaunchInFlight = false;
2780
2809
  persistentFallbackOwnershipMonitor = null;
2781
2810
  persistentFallbackCancellationState = null;
2782
- profilePoolReusable = true;
2783
2811
  resolveStartCancellation = null;
2784
2812
  startCancellation = new Promise((resolveCancellation) => {
2785
2813
  this.resolveStartCancellation = resolveCancellation;
@@ -2807,6 +2835,7 @@ export class BrowserController {
2807
2835
  // siblings and fails-fast on genuinely out-of-scope in-page API calls. When
2808
2836
  // null/never set (harness replay, non-session use) the guard is inert.
2809
2837
  hostScopeAllowedHostsProvider = null;
2838
+ operationScopedAllowedHosts = new Map();
2810
2839
  hostScopeGuardInstallation = null;
2811
2840
  // Feed the current session's allowed hosts to the request-scope guard. Read
2812
2841
  // lazily per request, so allow_host / auto-widen updates take effect without
@@ -2815,7 +2844,7 @@ export class BrowserController {
2815
2844
  // sessions, never for harness/replay or non-session browsers.
2816
2845
  async setHostScopeAllowedHosts(provider, siblingDomainProvider = provider) {
2817
2846
  this.hostScopeAllowedHostsProvider = () => ({
2818
- allowedHosts: provider(),
2847
+ allowedHosts: [...provider(), ...this.operationScopedAllowedHosts.keys()],
2819
2848
  siblingDomainHosts: siblingDomainProvider(),
2820
2849
  });
2821
2850
  this.hostScopeGuardInstallation ??= this.installHostScopeGuard().catch((error) => {
@@ -2824,6 +2853,24 @@ export class BrowserController {
2824
2853
  });
2825
2854
  await this.hostScopeGuardInstallation;
2826
2855
  }
2856
+ async withTemporaryHostScopeAllowedHosts(hosts, operation) {
2857
+ const normalized = [...new Set(hosts.map((host) => host.trim().toLowerCase()).filter(Boolean))];
2858
+ for (const host of normalized) {
2859
+ this.operationScopedAllowedHosts.set(host, (this.operationScopedAllowedHosts.get(host) ?? 0) + 1);
2860
+ }
2861
+ try {
2862
+ return await operation();
2863
+ }
2864
+ finally {
2865
+ for (const host of normalized) {
2866
+ const remaining = (this.operationScopedAllowedHosts.get(host) ?? 1) - 1;
2867
+ if (remaining <= 0)
2868
+ this.operationScopedAllowedHosts.delete(host);
2869
+ else
2870
+ this.operationScopedAllowedHosts.set(host, remaining);
2871
+ }
2872
+ }
2873
+ }
2827
2874
  // Defect-A fail-fast request-scope guard. Only XHR/fetch subresource API
2828
2875
  // calls are scope-guarded; page-load resources (scripts/styles/images/frames)
2829
2876
  // always continue, so a legitimate render is never broken by the guard. A
@@ -2866,61 +2913,98 @@ export class BrowserController {
2866
2913
  oauthProductPage = null;
2867
2914
  oauthProviderPage = null;
2868
2915
  oauthProviderPageClosed = false;
2869
- // Deep-investigation instrumentation (UNIVERSAL_BOT_OAUTH_DEBUG): a ring
2870
- // buffer of OAuth/SSO-relevant network responses, so we can see WHY a Clerk/
2871
- // Stytch SSO callback fails to persist a session (cookie not set, FAPI
2872
- // rejection, etc.) without guessing. Off by default; zero cost when unset.
2873
- oauthNetLog = [];
2874
- oauthNetListenerAttached = false;
2875
- // F13 — on-demand Xvfb. Set when start() determined the host has no
2876
- // display surface but Xvfb is available, so Chrome can run with
2877
- // `headless: false` against a virtual display (Cloudflare/Stytch et
2878
- // al. detect Chromium-headless and block their signup forms). Torn
2879
- // down by close().
2880
- xvfb = null;
2881
- // F13 — which launch path start() took. Surfaced via .launchMode so
2882
- // the agent can push it into the run's step trail and we can see
2883
- // (from outside the box) whether the bot ran headed.
2916
+ // Surfaced in the run trail so operators can distinguish local headed,
2917
+ // remote, and headless launches.
2884
2918
  launchedMode = "unknown";
2885
2919
  get launchMode() {
2886
2920
  return this.launchedMode;
2887
2921
  }
2888
2922
  constructor(opts = {}) {
2889
2923
  this.humanize = opts.humanize ?? true;
2890
- this.profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
2891
- this.profileOperationLease = opts.profileOperationLease ?? null;
2924
+ this.profileDir = opts.profileDir ?? "";
2892
2925
  this.proxyOverride =
2893
2926
  opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
2894
2927
  }
2928
+ trackMainDocument(page) {
2929
+ if (this.trackedMainDocumentPages.has(page))
2930
+ return;
2931
+ this.trackedMainDocumentPages.add(page);
2932
+ this.mainDocumentIdentities.set(page, ++this.mainDocumentSequence);
2933
+ // A REPLACED main document advances the identity; a same-document History
2934
+ // API navigation does not. Playwright emits `framenavigated` for both, so
2935
+ // keying on it made every `history.replaceState` inside an SPA checkout
2936
+ // retire every operator ref mid-form — the identity churned faster than a
2937
+ // multi-field address block could be filled. `domcontentloaded` fires once
2938
+ // per real main-frame document (playwright's client `Frame` gates it on
2939
+ // `!this._parentFrame`), which is exactly the document-replacement signal.
2940
+ // A same-document route change to a genuinely different logical page is
2941
+ // still caught by the observation epoch's normalized origin+pathname fold
2942
+ // (compactV2EpochDoc), which is the backstop this narrowing relies on.
2943
+ page.on("domcontentloaded", () => {
2944
+ this.mainDocumentIdentities.set(page, ++this.mainDocumentSequence);
2945
+ });
2946
+ }
2947
+ mainDocumentIdentity() {
2948
+ const page = this.page;
2949
+ if (page === null)
2950
+ return "none";
2951
+ this.trackMainDocument(page);
2952
+ return String(this.mainDocumentIdentities.get(page));
2953
+ }
2895
2954
  /** Attach normal controller behavior to a harness-owned Playwright page. */
2896
2955
  static fromHarnessPage(page) {
2897
2956
  const controller = new BrowserController({ humanize: false });
2898
2957
  controller.context = page.context();
2899
2958
  controller.page = page;
2900
2959
  controller.primaryPage = page;
2960
+ controller.trackMainDocument(page);
2961
+ controller.trackOpenedTabs(page.context());
2901
2962
  controller.harnessAttachedPage = true;
2902
2963
  controller.launchedMode = "headless";
2903
2964
  return controller;
2904
2965
  }
2966
+ // Record every page the CONTEXT opens. Covers window.open popups and
2967
+ // target=_blank tabs alike; Playwright emits the same context-level "page"
2968
+ // event for both.
2969
+ trackOpenedTabs(context) {
2970
+ context.on("page", (page) => {
2971
+ this.trackMainDocument(page);
2972
+ this.openedTabs.push(page);
2973
+ // Bounded: adoption only ever reads the newest followable entry, and a
2974
+ // controller driven by something other than the operator's click path
2975
+ // never arms (and so never drains) this queue.
2976
+ if (this.openedTabs.length > 8)
2977
+ this.openedTabs.splice(0, this.openedTabs.length - 8);
2978
+ });
2979
+ }
2905
2980
  // Per-launch egress override. null means direct egress. Explicit overrides
2906
2981
  // are never subject to host-network classification.
2907
2982
  proxyOverride;
2908
- // Warm reuse is deliberately narrow: a browser belongs to exactly one
2909
- // persistent profile and one explicit egress override. Undefined/blank proxy
2910
- // values both mean direct egress.
2911
- matchesLaunchOptions(opts) {
2912
- const profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
2913
- const proxyUrl = opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
2914
- return this.profileDir === profileDir && this.proxyOverride === proxyUrl;
2915
- }
2916
- /** Identity persisted by the operator profile lease for owner-safe crash recovery. */
2917
- operatorWorkerIdentity() {
2918
- return this.childChromeIdentity ?? this.launchedProfileHolderIdentity;
2919
- }
2920
2983
  operatorBrowserMarker() {
2921
2984
  this.operatorProcessMarker ??= createOperatorBrowserMarker();
2922
2985
  return this.operatorProcessMarker;
2923
2986
  }
2987
+ async ownedHeadedBrowserEnvironment() {
2988
+ if (this.ownedDisplayRig === null) {
2989
+ const { createXvfbDisplayRig, startRemoteLoginDisplay } = await import("./remote-login-display.js");
2990
+ const rig = createXvfbDisplayRig();
2991
+ this.ownedDisplayRig = rig;
2992
+ await startRemoteLoginDisplay(rig);
2993
+ }
2994
+ const { remoteLoginEnvironment } = await import("./remote-login-display.js");
2995
+ const rig = this.ownedDisplayRig;
2996
+ if (rig === null)
2997
+ throw new Error("headed operator display did not start");
2998
+ return remoteLoginEnvironment(rig, process.env);
2999
+ }
3000
+ async teardownOwnedDisplay() {
3001
+ const rig = this.ownedDisplayRig;
3002
+ this.ownedDisplayRig = null;
3003
+ if (rig === null)
3004
+ return;
3005
+ const { teardownRemoteLoginRig } = await import("./remote-login-display.js");
3006
+ await teardownRemoteLoginRig(rig);
3007
+ }
2924
3008
  adoptOwnedChromeProcessTree(identity, processGroup) {
2925
3009
  if (this.ownedChromeProcessTreeProof?.identity.pid === identity.pid &&
2926
3010
  this.ownedChromeProcessTreeProof.identity.start_time === identity.start_time) {
@@ -2930,6 +3014,12 @@ export class BrowserController {
2930
3014
  const proof = tracked?.identity.start_time === identity.start_time
2931
3015
  ? tracked.proof
2932
3016
  : trackOwnedChromeProcessTree(identity, processGroup);
3017
+ if (proof !== null && this.ownerLaunchTracked) {
3018
+ if (!bindOwnerBrowserLaunch(this.operatorBrowserMarker(), proof.identity)) {
3019
+ releaseOwnedChromeProcessTree(proof);
3020
+ throw new Error("local browser launch identity could not be bound to owner custody");
3021
+ }
3022
+ }
2933
3023
  if (proof !== null)
2934
3024
  this.ownedChromeProcessTreeProof = proof;
2935
3025
  return proof;
@@ -2941,7 +3031,7 @@ export class BrowserController {
2941
3031
  : { proof: this.ownedChromeProcessTreeProof }),
2942
3032
  });
2943
3033
  }
2944
- // Required health gate for a warm browser. BrowserContext alone is not a
3034
+ // Required health gate for a live session browser. BrowserContext alone is not a
2945
3035
  // sufficient signal: a dead CDP transport can leave stale JS objects behind.
2946
3036
  isConnected() {
2947
3037
  const browser = this.cdpBrowser ?? this.context?.browser() ?? null;
@@ -3008,13 +3098,12 @@ export class BrowserController {
3008
3098
  }
3009
3099
  return ctx;
3010
3100
  }
3011
- const endpoint = await withChromeStartupLock(async () => {
3012
- this.throwIfStartCancelled();
3013
- const port = await findFreePort();
3101
+ const endpoint = await (async () => {
3014
3102
  this.throwIfStartCancelled();
3015
3103
  clearStaleSingletonLock(this.profileDir);
3104
+ rmSync(join(this.profileDir, DEVTOOLS_ACTIVE_PORT_FILE), { force: true });
3016
3105
  const argv = [
3017
- `--remote-debugging-port=${port}`,
3106
+ "--remote-debugging-port=0",
3018
3107
  "--remote-debugging-address=127.0.0.1",
3019
3108
  `--user-data-dir=${this.profileDir}`,
3020
3109
  "--no-first-run",
@@ -3025,16 +3114,16 @@ export class BrowserController {
3025
3114
  "--lang=en-US",
3026
3115
  ...params.args,
3027
3116
  ...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
3028
- ...(params.headless ? ["--headless=new"] : []),
3029
3117
  "about:blank",
3030
3118
  ];
3031
3119
  this.commitProfileLaunch();
3032
- const child = spawn(params.binary, argv, {
3120
+ const child = spawnLocalBrowser(params.binary, argv, this.profileDir, {
3033
3121
  env: params.env,
3034
3122
  stdio: ["ignore", "ignore", "pipe"],
3035
3123
  // A dedicated process group gives the session a single, identity-
3036
3124
  // proven teardown target for Chrome plus every renderer/GPU helper.
3037
3125
  detached: process.platform !== "win32",
3126
+ marker: this.operatorBrowserMarker(),
3038
3127
  });
3039
3128
  this.childChrome = child;
3040
3129
  this.childChromeProcessGroup = process.platform !== "win32";
@@ -3055,7 +3144,7 @@ export class BrowserController {
3055
3144
  throw new Error("BrowserController start cancelled");
3056
3145
  }
3057
3146
  try {
3058
- const endpoint = await waitForDevtools(port, 30_000);
3147
+ const endpoint = await waitForOwnedDevtoolsEndpoint(this.profileDir, 30_000, child);
3059
3148
  this.childChromeIdentity = await resolveAttachedProfileChildIdentity(child, this.profileDir, this.childChromeIdentity, { processGroup: this.childChromeProcessGroup });
3060
3149
  if (process.platform === "linux" && this.childChromeIdentity === null) {
3061
3150
  throw new Error("self-launched Chrome exited before identity was proven");
@@ -3088,7 +3177,7 @@ export class BrowserController {
3088
3177
  throw new Error(`${err instanceof Error ? err.message : String(err)}; Chrome pid=${child.pid ?? "unknown"} alive=${alive ? 1 : 0}` +
3089
3178
  `${chromeExit}${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
3090
3179
  }
3091
- }, { deadlineMs: 0 });
3180
+ })();
3092
3181
  // Use the patchright launcher's connectOverCDP — it's the exact path the
3093
3182
  // falsification experiment validated (its connect avoids Runtime.enable,
3094
3183
  // which a plain attach would emit). The anti-detection that matters here
@@ -3194,6 +3283,9 @@ export class BrowserController {
3194
3283
  console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
3195
3284
  }
3196
3285
  async start() {
3286
+ if (this.profileDir.length === 0) {
3287
+ throw new Error("BrowserController.start requires a per-session profile directory");
3288
+ }
3197
3289
  if (this.closePromise !== null)
3198
3290
  throw new Error("BrowserController is already closing");
3199
3291
  this.startPromise ??= this.startOnce();
@@ -3203,24 +3295,17 @@ export class BrowserController {
3203
3295
  const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
3204
3296
  if (!remoteMode)
3205
3297
  startGlobalOperatorBrowserProcessWatchdog();
3206
- const lease = this.profileOperationLease ??
3207
- (remoteMode ? null : await acquireFreeProfileOperationGuard(this.profileDir));
3208
- this.profileOperationLease = lease;
3209
3298
  try {
3210
- await this.startWithProfileGuard();
3299
+ await this.startBrowser();
3211
3300
  if (this.startCancellationRequested) {
3212
- await this.closeWithProfileGuard();
3301
+ await this.closeBrowser();
3213
3302
  throw new Error("BrowserController start cancelled");
3214
3303
  }
3215
3304
  }
3216
3305
  catch (err) {
3306
+ await this.teardownOwnedDisplay().catch(() => undefined);
3217
3307
  if (this.startCancellationRequested && this.persistentFallbackCancellationState === null) {
3218
- await this.closeWithProfileGuard().catch(() => undefined);
3219
- }
3220
- if (this.persistentFallbackCancellationState === null ||
3221
- this.persistentFallbackCancellationState === "closed") {
3222
- this.profileOperationLease = null;
3223
- lease?.release();
3308
+ await this.closeBrowser().catch(() => undefined);
3224
3309
  }
3225
3310
  throw err;
3226
3311
  }
@@ -3236,9 +3321,8 @@ export class BrowserController {
3236
3321
  this.throwIfStartCancelled();
3237
3322
  this.startLaunchCommitted = true;
3238
3323
  }
3239
- async startWithProfileGuard() {
3324
+ async startBrowser() {
3240
3325
  this.throwIfStartCancelled();
3241
- reapOrphanedBrowsersOnce(this.profileDir);
3242
3326
  const channel = await detectChromiumChannel();
3243
3327
  this.throwIfStartCancelled();
3244
3328
  this.launchedChannel = channel;
@@ -3253,7 +3337,7 @@ export class BrowserController {
3253
3337
  // (e.g. a Mac with a real GPU + residential egress) and we attach over CDP
3254
3338
  // across Tailscale. The remote machine IS a real device, so we spoof
3255
3339
  // NOTHING — no WebGL/device fingerprint patch (a fake-Intel string over a
3256
- // real Apple-GPU output would be its own mismatch tell), no local Xvfb, no
3340
+ // real Apple-GPU output would be its own mismatch tell), no local display, no
3257
3341
  // egress-geo override (the remote host's real timezone + residential IP are
3258
3342
  // authentic). software-WebGL output is exactly what the toughest anti-bot
3259
3343
  // (hCaptcha Enterprise) scores; only real hardware fixes the pixel
@@ -3261,15 +3345,20 @@ export class BrowserController {
3261
3345
  const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
3262
3346
  if (remoteMode) {
3263
3347
  console.error(`[operator] REMOTE-CDP mode — attaching to ${(process.env.BOT_CDP_ENDPOINT ?? "").trim()} ` +
3264
- `(real-host GPU + egress; local fingerprint spoof + Xvfb DISABLED)`);
3348
+ `(real-host GPU + egress; local fingerprint spoof + display setup disabled)`);
3349
+ }
3350
+ if (!remoteMode && !this.ownerLaunchTracked) {
3351
+ registerLocalBrowserLaunch(this.profileDir, process.env, this.operatorBrowserMarker());
3352
+ this.ownerLaunchTracked = true;
3265
3353
  }
3354
+ const browserEnv = remoteMode ? process.env : await this.ownedHeadedBrowserEnvironment();
3266
3355
  // T3.1: probe where this run's traffic actually exits so the
3267
3356
  // browser's declared timezone matches its egress IP (a US-timezone
3268
3357
  // browser on a foreign proxy IP is itself an anti-bot signal).
3269
3358
  // Done before the real launch: launchPersistentContext bakes the
3270
3359
  // timezone in at creation, with no way to set it afterward. Skipped in
3271
3360
  // remote mode — the remote host's own clock/IP are the authentic truth.
3272
- const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy);
3361
+ const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy, browserEnv);
3273
3362
  this.throwIfStartCancelled();
3274
3363
  if (geo !== null) {
3275
3364
  console.error(`[operator] egress geo: timezone=${geo.timezoneId}` +
@@ -3277,84 +3366,12 @@ export class BrowserController {
3277
3366
  ? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
3278
3367
  : ""));
3279
3368
  }
3280
- // F13 — decide whether to spin up Xvfb and run Chrome headed.
3281
- // Modern SaaS signups (Cloudflare/Stytch, Clerk, Auth0) detect
3282
- // Chromium-headless via JS fingerprints and gate their forms
3283
- // behind the check. Running headed against Xvfb defeats the gate
3284
- // — the user never sees the display.
3285
- //
3286
- // The decision matrix:
3287
- // - UNIVERSAL_BOT_HEADLESS=true (explicit opt-in): keep true
3288
- // headless. CI / Codespaces that lack Xvfb.
3289
- // - UNIVERSAL_BOT_HEADLESS=false (explicit opt-out): the
3290
- // current pre-F13 behavior — DISPLAY must exist already.
3291
- // - default + DISPLAY set: run headed against the existing
3292
- // display (laptop/desktop install).
3293
- // - default + no DISPLAY + Xvfb on PATH: spawn Xvfb, run
3294
- // headed against it (the headless-server install — what
3295
- // Cloudflare needed).
3296
- // - default + no DISPLAY + no Xvfb: fall back to true
3297
- // headless with a clear stderr warning.
3298
- let chromeEnv;
3299
- let chromeHeadless;
3300
- const explicitHeadless = process.env.UNIVERSAL_BOT_HEADLESS;
3301
- const hostHasDisplay = process.platform === "darwin" ||
3302
- process.platform === "win32" ||
3303
- (typeof process.env.DISPLAY === "string" && process.env.DISPLAY.length > 0);
3304
- if (explicitHeadless === "true") {
3305
- chromeHeadless = true;
3306
- this.launchedMode = "headless";
3307
- }
3308
- else if (explicitHeadless === "false") {
3309
- chromeHeadless = false;
3310
- this.launchedMode = "display";
3311
- }
3312
- else if (hostHasDisplay) {
3313
- chromeHeadless = false;
3314
- this.launchedMode = "display";
3315
- }
3316
- else if (xvfbAvailable()) {
3317
- try {
3318
- // 1920×1080 — the most common real desktop resolution. The old
3319
- // 1280×720 here was exactly Playwright's emulated-device viewport
3320
- // default (the code's own comments flag that as an anti-bot tell),
3321
- // and with viewport:null the page read it straight back. A 720p
3322
- // screen whose availHeight==height (no taskbar) is a headless
3323
- // signature strict Turnstiles (exa/cartesia) score against.
3324
- const xvfb = await startXvfb({ width: 1920, height: 1080 });
3325
- if (this.startCancellationRequested) {
3326
- xvfb.stop();
3327
- this.throwIfStartCancelled();
3328
- }
3329
- this.xvfb = xvfb;
3330
- chromeEnv = { ...process.env, DISPLAY: xvfb.display };
3331
- chromeHeadless = false;
3332
- this.launchedMode = "xvfb";
3333
- console.error(`[operator] no DISPLAY — spawned Xvfb at ${this.xvfb.display} for headed Chrome`);
3334
- }
3335
- catch (err) {
3336
- console.error(`[operator] Xvfb failed (${err instanceof Error ? err.message : String(err)}) — ` +
3337
- `falling back to true headless; Cloudflare/Stytch-class signups may fail`);
3338
- chromeHeadless = true;
3339
- this.launchedMode = "headless";
3340
- }
3341
- }
3342
- else {
3343
- console.error(`[operator] no DISPLAY and Xvfb not installed — running true headless. ` +
3344
- `For Cloudflare/Stytch-class signups install xvfb: apt-get install -y xvfb`);
3345
- chromeHeadless = true;
3346
- this.launchedMode = "headless";
3347
- }
3348
- const free = await waitForProfileFree(this.profileDir, { deadlineMs: 0 });
3349
- this.throwIfStartCancelled();
3350
- if (!free) {
3351
- throw new ProfileBusyError(PROFILE_BUSY_MESSAGE);
3352
- }
3353
- // T3: a PERSISTENT context. The profile dir carries the user's
3354
- // Google session (established by `mcp login` — see google-login.ts),
3355
- // so the OAuth-first signup path reuses it instead of starting
3356
- // logged-out. launchPersistentContext takes launch + context
3357
- // options in one call.
3369
+ // Keep the operator browser headed: the browser runs on the operator's
3370
+ // Xvfb display, preserving the normal Chrome surface OAuth providers see.
3371
+ this.launchedMode = "headed";
3372
+ // T3: a PERSISTENT context backed by this operator session's unique
3373
+ // profile. launchPersistentContext takes launch + context options in one
3374
+ // call.
3358
3375
  // Resolve the launcher first so activeStealthProfile is set before we
3359
3376
  // decide on executablePath below.
3360
3377
  const launcher = getChromium();
@@ -3368,7 +3385,7 @@ export class BrowserController {
3368
3385
  this.launchedChannel = channel;
3369
3386
  // Launch args shared by BOTH paths (launchPersistentContext and the
3370
3387
  // self-launch). See the per-flag rationale: swiftshader gives a real
3371
- // (software) WebGL context on the GPU-less Xvfb box; the others are the
3388
+ // (software) WebGL context on GPU-less hosts; the others are the
3372
3389
  // standard headless/sandbox flags. The three background-throttling disables
3373
3390
  // are payment correctness controls: a backgrounded CardinalCommerce ACS
3374
3391
  // frame must keep running its timers long enough to finish the issuer's OOB
@@ -3403,25 +3420,23 @@ export class BrowserController {
3403
3420
  this.throwIfStartCancelled();
3404
3421
  if (useSelfLaunch && selfLaunchBinary !== null) {
3405
3422
  console.error(`[operator] self-launch + connectOverCDP (Turnstile-safe launch) binary=${selfLaunchBinary}`);
3406
- const window = this.launchedMode === "xvfb"
3407
- ? { width: 1920, height: 1080 }
3408
- : { width: 1280, height: 1024 };
3423
+ const window = { width: 1280, height: 1024 };
3409
3424
  const selfEnv = {
3410
- ...(chromeEnv ?? process.env),
3425
+ ...browserEnv,
3411
3426
  TZ: geo?.timezoneId ?? "America/New_York",
3412
3427
  [OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
3413
3428
  };
3414
- context = await launchWithProfileGate(this.profileDir, () => {
3429
+ const launch = () => {
3415
3430
  this.throwIfStartCancelled();
3416
3431
  return this.launchSelfManagedContext({
3417
3432
  binary: selfLaunchBinary,
3418
- headless: chromeHeadless,
3419
3433
  args: launchArgs,
3420
3434
  proxy,
3421
3435
  env: selfEnv,
3422
3436
  window,
3423
3437
  });
3424
- }, { failFast: true });
3438
+ };
3439
+ context = await launch();
3425
3440
  try {
3426
3441
  await context.grantPermissions(grantedPermissions);
3427
3442
  if (geo?.geolocation !== undefined) {
@@ -3433,7 +3448,6 @@ export class BrowserController {
3433
3448
  }
3434
3449
  }
3435
3450
  else {
3436
- this.profilePoolReusable = false;
3437
3451
  this.persistentFallbackLaunchInFlight = true;
3438
3452
  this.startPersistentFallbackOwnershipMonitor();
3439
3453
  const cleanupProfileHolder = async () => {
@@ -3450,7 +3464,7 @@ export class BrowserController {
3450
3464
  return (await this.waitForOwnedProfileExit(identity, treeProof)) ? "closed" : "unknown";
3451
3465
  };
3452
3466
  const cleanupCancelled = async (lateContext) => {
3453
- const proof = await this.waitForPersistentFallbackIdentity();
3467
+ const proof = await this.waitForPersistentFallbackIdentity().catch(() => ({ state: "unknown" }));
3454
3468
  if (proof.state !== "owned") {
3455
3469
  await lateContext.close().catch(() => undefined);
3456
3470
  return proof.state === "absent" ? "closed" : "unknown";
@@ -3478,11 +3492,11 @@ export class BrowserController {
3478
3492
  const outcome = await (async () => {
3479
3493
  try {
3480
3494
  return await launchCancellablePersistentContext({
3481
- launch: (options) => launchWithProfileGate(this.profileDir, () => launcher.launchPersistentContext(this.profileDir, options), { failFast: true }),
3495
+ launch: (options) => launcher.launchPersistentContext(this.profileDir, options),
3482
3496
  options: {
3483
- headless: chromeHeadless,
3497
+ headless: OPERATOR_BROWSER_HEADLESS,
3484
3498
  env: {
3485
- ...(chromeEnv ?? process.env),
3499
+ ...browserEnv,
3486
3500
  [OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
3487
3501
  },
3488
3502
  ...(channel !== null ? { channel } : {}),
@@ -3522,12 +3536,23 @@ export class BrowserController {
3522
3536
  }
3523
3537
  this.context = context;
3524
3538
  this.launchedContext = true;
3525
- const holderPid = currentProfileHolderPid(this.profileDir);
3526
- this.launchedProfileHolderIdentity =
3527
- holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir);
3528
- if (this.launchedProfileHolderIdentity !== null) {
3529
- this.adoptOwnedChromeProcessTree(this.launchedProfileHolderIdentity, false);
3530
- }
3539
+ this.launchedProfileHolderIdentity = await this.requirePersistentFallbackOwnership(async () => {
3540
+ markOwnerBrowserLaunchTerminal(this.operatorBrowserMarker());
3541
+ await Promise.race([
3542
+ context.close().catch(() => undefined),
3543
+ new Promise((resolveWait) => {
3544
+ const timer = setTimeout(resolveWait, PERSISTENT_CONTEXT_CANCELLATION_SETTLE_MS);
3545
+ timer.unref();
3546
+ }),
3547
+ ]);
3548
+ const markerClosed = await terminateOwnerBrowserLaunch(this.operatorBrowserMarker(), this.profileDir);
3549
+ if (markerClosed) {
3550
+ untrackOwnerBrowserLaunch(this.operatorBrowserMarker());
3551
+ this.ownerLaunchTracked = false;
3552
+ }
3553
+ this.context = null;
3554
+ this.launchedContext = false;
3555
+ });
3531
3556
  this.commitProfileLaunch();
3532
3557
  this.persistentFallbackLaunchInFlight = false;
3533
3558
  }
@@ -3544,7 +3569,7 @@ export class BrowserController {
3544
3569
  }
3545
3570
  }
3546
3571
  if (this.startCancellationRequested) {
3547
- await this.closeWithProfileGuard();
3572
+ await this.closeBrowser();
3548
3573
  throw new Error("BrowserController start cancelled");
3549
3574
  }
3550
3575
  // Speed: optionally abort heavy/irrelevant requests before any navigation.
@@ -3647,7 +3672,7 @@ export class BrowserController {
3647
3672
  get: () => 8,
3648
3673
  configurable: true,
3649
3674
  });
3650
- // Screen availHeight tell: a headless Xvfb screen reports
3675
+ // Screen availHeight tell: a virtual screen reports
3651
3676
  // availHeight == height (no OS taskbar), whereas a real Windows
3652
3677
  // desktop reserves ~40px for the taskbar (availHeight = height-40,
3653
3678
  // availWidth = width). Reinstate that gap so the screen reads like
@@ -3678,7 +3703,11 @@ export class BrowserController {
3678
3703
  if (contextInitScripts.includes("webgl-spoof")) {
3679
3704
  await context.addInitScript({ content: installWebglSpoofScript });
3680
3705
  }
3706
+ for (const page of context.pages())
3707
+ this.trackMainDocument(page);
3708
+ this.trackOpenedTabs(context);
3681
3709
  this.page = context.pages()[0] ?? (await context.newPage());
3710
+ this.trackMainDocument(this.page);
3682
3711
  this.primaryPage = this.page;
3683
3712
  // In baseline mode addInitScript covers document-start page JS, but
3684
3713
  // Playwright's page.evaluate utility execution can run in a separate realm.
@@ -3805,7 +3834,7 @@ export class BrowserController {
3805
3834
  // timezone has to be known before it is. The throwaway inherits the
3806
3835
  // same channel + proxy so it reports the real egress. Best-effort —
3807
3836
  // any failure returns null and start() keeps a default timezone.
3808
- async probeEgressGeo(channel, proxy) {
3837
+ async probeEgressGeo(channel, proxy, browserEnv) {
3809
3838
  if (proxy === null) {
3810
3839
  try {
3811
3840
  const resp = await fetch("https://ipinfo.io/json", { signal: AbortSignal.timeout(10_000) });
@@ -3822,7 +3851,8 @@ export class BrowserController {
3822
3851
  let probe;
3823
3852
  try {
3824
3853
  probe = await getChromium().launch({
3825
- headless: process.env.UNIVERSAL_BOT_HEADLESS !== "false",
3854
+ headless: OPERATOR_BROWSER_HEADLESS,
3855
+ env: browserEnv,
3826
3856
  ...(channel !== null ? { channel } : {}),
3827
3857
  ...(proxy !== null ? { proxy } : {}),
3828
3858
  args: ["--no-sandbox", "--disable-dev-shm-usage"],
@@ -4113,7 +4143,28 @@ export class BrowserController {
4113
4143
  async type(selector, text, sealed = false) {
4114
4144
  if (!this.page)
4115
4145
  throw new Error("Browser not started");
4116
- return await this.withModalInertNeutralized(selector, () => this.typeInner(selector, text, sealed));
4146
+ await this.withModalInertNeutralized(selector, () => this.typeInner(selector, text, sealed));
4147
+ }
4148
+ /**
4149
+ * Commit Shopify's required shipping street field after type/autocomplete.
4150
+ * Shopify Places only begins geocoding on this field's change/blur boundary;
4151
+ * dispatching change and moving focus away mirrors the user's Tab action.
4152
+ * This is intentionally not a generic post-type event mechanism.
4153
+ */
4154
+ async commitRequiredShippingAddressLine1(selector) {
4155
+ if (!this.page)
4156
+ throw new Error("Browser not started");
4157
+ await this.page
4158
+ .locator(selector)
4159
+ .first()
4160
+ .evaluate((field) => {
4161
+ field.dispatchEvent(new Event("change", { bubbles: true }));
4162
+ if (field instanceof HTMLElement)
4163
+ field.blur();
4164
+ });
4165
+ // Allow Shopify's bounded Places/geocoding request to begin before the
4166
+ // caller observes the delivery-method section.
4167
+ await this.sleep(500);
4117
4168
  }
4118
4169
  async typeInner(selector, text, sealed = false) {
4119
4170
  if (!this.page)
@@ -4121,16 +4172,16 @@ export class BrowserController {
4121
4172
  // Wait for element to be visible and enabled before typing.
4122
4173
  await this.page.waitForSelector(selector, { state: "visible", timeout: 10000 });
4123
4174
  const locator = this.page.locator(selector);
4124
- const sealedFieldKeys = sealed
4125
- ? await this.operatorScreenshotIdentityKeys(locator, this.page.mainFrame())
4126
- : [];
4175
+ // The marker is payment machinery — the card-clearing and saved-card
4176
+ // resolution passes find the fields they filled through it. It is not a
4177
+ // read seal: nothing masks or refuses a read because of it.
4127
4178
  if (sealed) {
4128
4179
  await locator.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
4129
4180
  }
4130
4181
  if (!this.humanize) {
4131
4182
  // Fast path for tests / non-humanized runs.
4132
4183
  await this.page.fill(selector, text);
4133
- return sealedFieldKeys;
4184
+ return;
4134
4185
  }
4135
4186
  // Humanized typing:
4136
4187
  // - Click into the field first (moves mouse, generates focus event)
@@ -4161,7 +4212,6 @@ export class BrowserController {
4161
4212
  // hook, and over-engineering it added zero observable behavior-
4162
4213
  // score improvement.
4163
4214
  await locator.pressSequentially(text, { delay: rand(40, 110) });
4164
- return sealedFieldKeys;
4165
4215
  }
4166
4216
  // Best-effort scan for the SPECIFIC unfilled required field(s) blocking a
4167
4217
  // disabled submit. Returns a " Unfilled required field(s) — …" suffix for the
@@ -5149,20 +5199,16 @@ export class BrowserController {
5149
5199
  const ownerFrame = await handle.ownerFrame();
5150
5200
  if (ownerFrame === null)
5151
5201
  throw new Error("locator target has no owning frame");
5152
- const sealedFieldKeys = sealed
5153
- ? await this.operatorScreenshotIdentityKeys(handle, ownerFrame)
5154
- : [];
5155
5202
  if (sealed) {
5156
5203
  await handle.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
5157
5204
  }
5158
5205
  if (!this.humanize) {
5159
5206
  await handle.fill(text);
5160
- return sealedFieldKeys;
5207
+ return;
5161
5208
  }
5162
5209
  await handle.click({ timeout: 8000 }).catch(() => undefined);
5163
5210
  await handle.fill("").catch(() => undefined);
5164
5211
  await handle.type(text, { delay: rand(40, 110) });
5165
- return sealedFieldKeys;
5166
5212
  }
5167
5213
  // Dispatch a DOM .click() in the page context. Some React copy buttons fire
5168
5214
  // their onClick (and thus navigator.clipboard.writeText) on the synthetic
@@ -6761,7 +6807,7 @@ export class BrowserController {
6761
6807
  // device memory, screen, languages, webdriver flag. Turnstile
6762
6808
  // error 600010 ("internal client execution error") usually points
6763
6809
  // at one of these returning something the challenge JS can't
6764
- // handle (e.g. SwiftShader/llvmpipe renderer under Xvfb).
6810
+ // handle (e.g. a SwiftShader/llvmpipe renderer).
6765
6811
  if (process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1") {
6766
6812
  try {
6767
6813
  const fp = await this.page.evaluate(() => {
@@ -7642,10 +7688,6 @@ export class BrowserController {
7642
7688
  hasHcaptchaGlobal: win.hcaptcha !== undefined,
7643
7689
  };
7644
7690
  }, { tok: token, key: responseKey });
7645
- if (process.env.UNIVERSAL_BOT_OAUTH_DEBUG) {
7646
- console.error(`[captcha] hCaptcha inject diag: ok=${diag.ok} textareas=${diag.textareas} ` +
7647
- `widgets=${diag.widgets} callbackFired=${diag.callbackFired} hcaptchaGlobal=${diag.hasHcaptchaGlobal}`);
7648
- }
7649
7691
  return diag.ok;
7650
7692
  }
7651
7693
  catch {
@@ -7864,582 +7906,78 @@ export class BrowserController {
7864
7906
  }
7865
7907
  return null;
7866
7908
  }
7867
- async operatorScreenshotCaptureScope(targetFrame) {
7868
- if (!this.page)
7869
- throw new Error("Browser not started");
7870
- if (targetFrame === null || targetFrame === this.page.mainFrame()) {
7871
- const frames = this.page.frames();
7872
- return { frames, strictFrames: new Set(frames), clip: null };
7873
- }
7874
- const strictFrames = new Set();
7875
- const visit = (frame) => {
7876
- strictFrames.add(frame);
7877
- for (const child of frame.childFrames())
7878
- visit(child);
7879
- };
7880
- visit(targetFrame);
7881
- const targetHandle = await targetFrame.frameElement();
7882
- const clip = await targetHandle.boundingBox();
7883
- await targetHandle.dispose().catch(() => undefined);
7884
- if (clip === null)
7885
- throw new Error("screenshot_redaction_unresolved");
7886
- const intersects = (box) => box.x < clip.x + clip.width &&
7887
- box.x + box.width > clip.x &&
7888
- box.y < clip.y + clip.height &&
7889
- box.y + box.height > clip.y;
7890
- const frames = [];
7891
- for (const frame of this.page.frames()) {
7892
- if (frame === this.page.mainFrame() || strictFrames.has(frame)) {
7893
- frames.push(frame);
7894
- continue;
7895
- }
7896
- const handle = await frame.frameElement();
7897
- try {
7898
- const box = await handle.boundingBox();
7899
- if (box !== null && intersects(box))
7900
- frames.push(frame);
7901
- }
7902
- finally {
7903
- await handle.dispose().catch(() => undefined);
7904
- }
7905
- }
7906
- return { frames, strictFrames, clip };
7907
- }
7908
- async sealedDocumentIdentity(frame) {
7909
- const current = this.sealedDocuments.get(frame);
7910
- if (current !== undefined &&
7911
- !frame.isDetached() &&
7912
- (await frame.evaluate((expected) => document === expected, current.handle).catch(() => false))) {
7913
- return current.identity;
7914
- }
7915
- await current?.handle.dispose().catch(() => undefined);
7916
- const handle = await frame.evaluateHandle(() => document);
7917
- const identity = `document-${this.sealedDocumentSequence++}`;
7918
- this.sealedDocuments.set(frame, { handle, identity });
7919
- return identity;
7920
- }
7921
- async operatorScreenshotIdentityKeys(target, frame) {
7922
- const descriptor = await target.evaluate((el) => {
7923
- const clean = (value) => {
7924
- const normalized = (value ?? "").replace(/\s+/g, " ").trim();
7925
- return normalized.length > 0 ? normalized : null;
7926
- };
7927
- const labelFor = (element) => {
7928
- const id = element.getAttribute("id");
7929
- if (id !== null && id.length > 0) {
7930
- const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
7931
- if (label !== null)
7932
- return clean(label.textContent);
7933
- }
7934
- const labelledBy = element.getAttribute("aria-labelledby");
7935
- if (labelledBy !== null) {
7936
- const text = labelledBy
7937
- .split(/\s+/)
7938
- .map((part) => clean(document.getElementById(part)?.textContent))
7939
- .filter((part) => part !== null)
7940
- .join(" ");
7941
- if (text.length > 0)
7942
- return text;
7943
- }
7944
- return clean(element.closest("label")?.textContent);
7945
- };
7946
- const controls = Array.from(el.getRootNode().querySelectorAll("input,textarea,select,[contenteditable='true']"));
7947
- return {
7948
- tag: el.tagName.toLowerCase(),
7949
- type: el.getAttribute("type"),
7950
- id: el.getAttribute("id"),
7951
- name: el.getAttribute("name"),
7952
- testId: el.getAttribute("data-testid") ??
7953
- el.getAttribute("data-test-id") ??
7954
- el.getAttribute("data-test") ??
7955
- el.getAttribute("data-cy") ??
7956
- el.getAttribute("data-qa"),
7957
- labelText: labelFor(el),
7958
- ariaLabel: el.getAttribute("aria-label"),
7959
- placeholder: el.getAttribute("placeholder"),
7960
- landmark: el.closest("header,main,footer,nav,aside,article,section")?.tagName.toLowerCase() ?? null,
7961
- ordinal: controls.indexOf(el),
7962
- };
7963
- });
7964
- const documentIdentity = await this.sealedDocumentIdentity(frame);
7965
- return sealedElementSemanticKeys(descriptor).map((key) => `${documentIdentity}:${key}`);
7966
- }
7967
- async resolveOperatorScreenshotSealedLocators(frames, sealedFieldKeys) {
7968
- const byFrame = new Map();
7969
- for (const frame of frames) {
7970
- const matches = [];
7971
- const candidates = await frame
7972
- .locator("input,textarea,select,[contenteditable='true']")
7973
- .all();
7974
- for (const candidate of candidates) {
7975
- const keys = await this.operatorScreenshotIdentityKeys(candidate, frame);
7976
- if (keys.some((key) => sealedFieldKeys.has(key)))
7977
- matches.push(candidate);
7978
- }
7979
- byFrame.set(frame, matches);
7980
- }
7981
- return byFrame;
7982
- }
7983
- // Resolve every card-shaped/sealed field in the captured frames to a
7984
- // screenshot redaction rectangles — text-masking (presentFieldValue in
7985
- // provision-session.ts) only covers the JSON observation, not a rendered
7986
- // image. Beyond the attribute-based
7987
- // selector set, every renderable input/textarea whose CURRENT value contains
7988
- // a Luhn-valid PAN span is masked too (containsLuhnPanSpan — the same
7989
- // detection the payment paths use), so a card number sitting in a field the
7990
- // fixed selectors don't recognize still never reaches the image. Fail-closed
7991
- // throughout: a selector that cannot be queried, a value that cannot be
7992
- // read, or a matched element whose geometry cannot be resolved (boundingBox
7993
- // returns null rather than throwing) aborts the whole capture rather than
7994
- // shrinking the redaction. The per-frame count signature lets the caller
7995
- // verify the redaction set stayed stable across the capture window.
7996
- async collectOperatorScreenshotMask(frames, extraRedactionSelectors, sealedLocators = new Map(), captureClip = null, strictFrames = new Set(frames)) {
7997
- const selector = [SCREENSHOT_REDACTION_SELECTORS, ...extraRedactionSelectors].join(",");
7998
- const rectangles = [];
7999
- const handles = [];
8000
- const signatureParts = [];
8001
- try {
8002
- for (const [frameIndex, frame] of frames.entries()) {
8003
- let frameCount = 0;
8004
- const frameHandles = await frame.locator(selector).elementHandles();
8005
- for (const locator of sealedLocators.get(frame) ?? []) {
8006
- const handle = await locator.elementHandle({ timeout: 5_000 });
8007
- if (handle === null)
8008
- throw new Error("screenshot_redaction_unresolved");
8009
- if (!(await handle.evaluate((el, matchSelector) => el.matches(matchSelector), selector))) {
8010
- frameHandles.push(handle);
8011
- }
8012
- else {
8013
- await handle.dispose();
8014
- }
8015
- }
8016
- const valueCandidates = await frame
8017
- .locator('input:not([type="hidden" i]),textarea')
8018
- .elementHandles();
8019
- for (const candidate of valueCandidates) {
8020
- const value = await candidate.inputValue({ timeout: 5_000 });
8021
- if (!containsLuhnPanSpan(value)) {
8022
- await candidate.dispose();
8023
- continue;
8024
- }
8025
- const duplicate = await Promise.all(frameHandles.map(async (existing) => await candidate.evaluate((el, other) => el === other, existing)));
8026
- if (duplicate.includes(true)) {
8027
- await candidate.dispose();
8028
- continue;
8029
- }
8030
- frameHandles.push(candidate);
8031
- }
8032
- for (const handle of frameHandles) {
8033
- if (!(await handle.evaluate((el) => el.isConnected))) {
8034
- throw new Error("screenshot_redaction_unresolved");
8035
- }
8036
- const box = await handle.boundingBox();
8037
- if (captureClip !== null &&
8038
- !strictFrames.has(frame) &&
8039
- (box === null ||
8040
- box.x >= captureClip.x + captureClip.width ||
8041
- box.x + box.width <= captureClip.x ||
8042
- box.y >= captureClip.y + captureClip.height ||
8043
- box.y + box.height <= captureClip.y)) {
8044
- await handle.dispose();
8045
- continue;
8046
- }
8047
- handles.push(handle);
8048
- if (box !== null)
8049
- rectangles.push(box);
8050
- signatureParts.push(`${frameIndex}:${box === null ? "hidden" : [box.x, box.y, box.width, box.height].join(":")}`);
8051
- frameCount += 1;
8052
- }
8053
- signatureParts.push(`count:${frameIndex}:${frameCount}`);
8054
- }
8055
- }
8056
- catch {
8057
- await Promise.all(handles.map(async (handle) => await handle.dispose().catch(() => undefined)));
8058
- throw new Error("screenshot_redaction_unresolved");
8059
- }
8060
- return {
8061
- rectangles,
8062
- redactedCount: handles.length,
8063
- signature: signatureParts.join("|"),
8064
- handles,
8065
- };
8066
- }
8067
- async redactOperatorScreenshot(buffer, rectangles, origin, captureSize) {
8068
- const metadata = await sharp(buffer).metadata();
8069
- if (metadata.width === undefined || metadata.height === undefined) {
8070
- throw new Error("screenshot_redaction_unresolved");
8071
- }
8072
- const scaleX = metadata.width / captureSize.width;
8073
- const scaleY = metadata.height / captureSize.height;
8074
- const rects = rectangles
8075
- .map((box) => `<rect x="${(box.x - origin.x) * scaleX}" y="${(box.y - origin.y) * scaleY}" width="${box.width * scaleX}" height="${box.height * scaleY}" fill="#ff00ff"/>`)
8076
- .join("");
8077
- const overlay = Buffer.from(`<svg width="${metadata.width}" height="${metadata.height}" xmlns="http://www.w3.org/2000/svg">${rects}</svg>`);
8078
- return (await sharp(buffer)
8079
- .composite([{ input: overlay, blend: "over" }])
8080
- .jpeg({ quality: 80 })
8081
- .toBuffer()).toString("base64");
8082
- }
8083
- // Verify the capture set before pixels are read. This is deliberately
8084
- // narrower than the capture-time mask: empty checkout controls are harmless,
8085
- // but a nonempty type_secret target, password, payment-sealed node, or
8086
- // Luhn-valid PAN means the requested image could contain a secret. Every
8087
- // frame included by the image must be readable; a detached or navigating
8088
- // frame is not evidence that it is safe to capture.
8089
- async assertOperatorScreenshotFramesNoSealedValues(frames, extraRedactionSelectors, sealedLocators = new Map(), captureClip = null, strictFrames = new Set(frames)) {
8090
- const sealedSelector = [SCREENSHOT_REDACTION_SELECTORS, ...extraRedactionSelectors].join(",");
8091
- try {
8092
- for (const frame of frames) {
8093
- if (frame.isDetached())
8094
- throw new Error("frame detached");
8095
- const sealedMatches = [
8096
- ...(await frame.locator(sealedSelector).all()),
8097
- ...(sealedLocators.get(frame) ?? []),
8098
- ];
8099
- for (const match of sealedMatches) {
8100
- const box = strictFrames.has(frame) ? null : await match.boundingBox();
8101
- if (captureClip !== null &&
8102
- !strictFrames.has(frame) &&
8103
- (box === null ||
8104
- box.x >= captureClip.x + captureClip.width ||
8105
- box.x + box.width <= captureClip.x ||
8106
- box.y >= captureClip.y + captureClip.height ||
8107
- box.y + box.height <= captureClip.y)) {
8108
- continue;
8109
- }
8110
- const hasValue = await match.evaluate((el) => {
8111
- if (el instanceof HTMLSelectElement) {
8112
- const optionText = el.options[el.selectedIndex]?.textContent ?? "";
8113
- const normalizedOption = optionText.replace(/\s+/g, " ").trim();
8114
- const placeholder = /^(?:select|choose)?\s*(?:a\s+)?(?:month|year|mm|yy)?\s*(?:\.\.\.|[-–—]*)$/i;
8115
- return (el.value.trim().length > 0 ||
8116
- (normalizedOption.length > 0 && !placeholder.test(normalizedOption)));
8117
- }
8118
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
8119
- return el.value.trim().length > 0;
8120
- }
8121
- return (el.textContent ?? "").trim().length > 0;
8122
- });
8123
- if (hasValue)
8124
- throw new Error("sealed value");
8125
- }
8126
- const candidates = await frame.locator('input:not([type="hidden" i]),textarea').all();
8127
- for (const candidate of candidates) {
8128
- const box = strictFrames.has(frame) ? null : await candidate.boundingBox();
8129
- if (captureClip !== null &&
8130
- !strictFrames.has(frame) &&
8131
- (box === null ||
8132
- box.x >= captureClip.x + captureClip.width ||
8133
- box.x + box.width <= captureClip.x ||
8134
- box.y >= captureClip.y + captureClip.height ||
8135
- box.y + box.height <= captureClip.y)) {
8136
- continue;
8137
- }
8138
- const hasPan = await candidate.evaluate((el) => {
8139
- const text = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : "";
8140
- const digits = Array.from(text.matchAll(/\d/g), (match) => match.index);
8141
- const luhn = (value) => {
8142
- let sum = 0;
8143
- let double = false;
8144
- for (let index = value.length - 1; index >= 0; index -= 1) {
8145
- let digit = Number(value[index]);
8146
- if (double) {
8147
- digit *= 2;
8148
- if (digit > 9)
8149
- digit -= 9;
8150
- }
8151
- sum += digit;
8152
- double = !double;
8153
- }
8154
- return sum % 10 === 0;
8155
- };
8156
- for (let start = 0; start + 13 <= digits.length; start += 1) {
8157
- const maxLength = Math.min(19, digits.length - start);
8158
- for (let length = 13; length <= maxLength; length += 1) {
8159
- const positions = digits.slice(start, start + length);
8160
- if (positions[positions.length - 1] - positions[0] + 1 >
8161
- PAYMENT_PAN_MAX_SPAN_CHARS) {
8162
- break;
8163
- }
8164
- if (luhn(positions.map((position) => text[position]).join("")))
8165
- return true;
8166
- }
8167
- }
8168
- return false;
8169
- });
8170
- if (hasPan)
8171
- throw new Error("card value");
8172
- }
8173
- if (strictFrames.has(frame) || captureClip === null || frame !== this.page?.mainFrame()) {
8174
- const renderedText = await frame.evaluate(() => document.body?.innerText ?? document.documentElement?.innerText ?? "");
8175
- if (containsLuhnPanSpan(renderedText))
8176
- throw new Error("rendered card value");
8177
- }
8178
- else {
8179
- const renderedTextInClip = await frame.evaluate((clip) => {
8180
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
8181
- const texts = [];
8182
- let node = walker.nextNode();
8183
- while (node !== null) {
8184
- const text = node.textContent ?? "";
8185
- if (/\d/.test(text)) {
8186
- const range = document.createRange();
8187
- range.selectNodeContents(node);
8188
- const overlaps = Array.from(range.getClientRects()).some((rect) => rect.left < clip.x + clip.width &&
8189
- rect.right > clip.x &&
8190
- rect.top < clip.y + clip.height &&
8191
- rect.bottom > clip.y);
8192
- if (overlaps)
8193
- texts.push(text);
8194
- }
8195
- node = walker.nextNode();
8196
- }
8197
- return texts.join(" ");
8198
- }, captureClip);
8199
- if (containsLuhnPanSpan(renderedTextInClip))
8200
- throw new Error("rendered card value");
8201
- }
8202
- }
8203
- }
8204
- catch {
8205
- throw new Error("screenshot_unavailable_sealed_context");
8206
- }
8207
- }
8208
- async captureOperatorScreenshot(opts = {}, sealedFieldKeys = []) {
7909
+ // Read-only pixel capture for operate_screenshot. It never navigates, clicks,
7910
+ // types, focuses, or mutates the DOM — and it never masks anything. What the
7911
+ // page renders is what the driving agent gets back.
7912
+ async captureOperatorScreenshot(opts = {}) {
7913
+ return await this.screenshotForOperator(opts);
7914
+ }
7915
+ async screenshotForOperator(opts = {}) {
8209
7916
  if (!this.page)
8210
7917
  throw new Error("Browser not started");
7918
+ const page = this.page;
8211
7919
  const targetFrame = this.resolveOperatorScreenshotFrame(opts);
8212
- const scope = await this.operatorScreenshotCaptureScope(targetFrame);
8213
- const { frames } = scope;
8214
- const documents = [];
7920
+ const cdp = await page.context().newCDPSession(page);
8215
7921
  try {
8216
- for (const frame of frames) {
8217
- if (frame.isDetached())
8218
- throw new Error("frame detached");
8219
- documents.push(await frame.evaluateHandle(() => document));
8220
- }
8221
- const sealedLocators = await this.resolveOperatorScreenshotSealedLocators(frames, new Set(sealedFieldKeys));
8222
- await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, scope.clip, scope.strictFrames);
8223
- const documentsStillCurrent = async () => {
8224
- if (documents.length !== frames.length)
8225
- return false;
8226
- for (let index = 0; index < frames.length; index += 1) {
8227
- const frame = frames[index];
8228
- if (frame.isDetached())
8229
- return false;
8230
- if (!(await frame.evaluate((expected) => document === expected, documents[index]))) {
8231
- return false;
8232
- }
8233
- }
8234
- return true;
8235
- };
8236
- if (!(await documentsStillCurrent())) {
8237
- throw new Error("screenshot_unavailable_sealed_context");
8238
- }
8239
- let captured;
8240
- let attemptScope = scope;
8241
- for (let attempt = 0; attempt < 2; attempt += 1) {
8242
- if (attempt > 0) {
8243
- attemptScope = await this.operatorScreenshotCaptureScope(targetFrame);
8244
- if (attemptScope.frames.length !== frames.length ||
8245
- !frames.every((frame, index) => attemptScope.frames[index] === frame)) {
8246
- throw new Error("screenshot_unavailable_sealed_context");
8247
- }
8248
- }
7922
+ // caret:"initial" is not needed here — the CDP capture never runs
7923
+ // Playwright's caret-hiding pass, so element styles stay untouched.
7924
+ let base64;
7925
+ if (targetFrame !== null && targetFrame !== page.mainFrame()) {
7926
+ const handle = await targetFrame.frameElement();
8249
7927
  try {
8250
- captured = await this.screenshotForOperatorResolved(opts, targetFrame, frames, sealedLocators, attemptScope, async () => {
8251
- if (!(await documentsStillCurrent())) {
8252
- throw new Error("screenshot_unavailable_sealed_context");
8253
- }
8254
- await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, attemptScope.clip, attemptScope.strictFrames);
8255
- if (!(await documentsStillCurrent())) {
8256
- throw new Error("screenshot_unavailable_sealed_context");
8257
- }
8258
- });
8259
- break;
8260
- }
8261
- catch (error) {
8262
- if (attempt === 0 &&
8263
- error instanceof Error &&
8264
- error.message === "screenshot_redaction_unstable") {
8265
- continue;
8266
- }
8267
- throw error;
8268
- }
8269
- }
8270
- if (captured === undefined)
8271
- throw new Error("screenshot_unavailable_sealed_context");
8272
- if (!(await documentsStillCurrent())) {
8273
- throw new Error("screenshot_unavailable_sealed_context");
8274
- }
8275
- await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, attemptScope.clip, attemptScope.strictFrames);
8276
- return captured;
8277
- }
8278
- catch (error) {
8279
- if (error instanceof Error && error.message === "screenshot_frame_not_found")
8280
- throw error;
8281
- if (error instanceof Error && error.message === "screenshot_unavailable_sealed_context") {
8282
- throw error;
8283
- }
8284
- throw new Error("screenshot_unavailable_sealed_context");
8285
- }
8286
- finally {
8287
- await Promise.all(documents.map(async (document) => await document.dispose().catch(() => undefined)));
8288
- }
8289
- }
8290
- // Low-level read-only capture used by captureOperatorScreenshot after its
8291
- // capture-scoped sealed-value checks, and directly by redaction tests. It
8292
- // never navigates, clicks, types, focuses, or mutates the DOM. Redaction
8293
- // covers every frame included in the image: the whole page composites its
8294
- // visible frames, while an isolated frame includes only that frame.
8295
- // `extraRedactionSelectors` is retained for callers that already resolved
8296
- // additional sensitive elements; production capture passes durable locators.
8297
- async screenshotForOperator(opts = {}) {
8298
- if (!this.page)
8299
- throw new Error("Browser not started");
8300
- const targetFrame = this.resolveOperatorScreenshotFrame(opts);
8301
- const scope = await this.operatorScreenshotCaptureScope(targetFrame);
8302
- return await this.screenshotForOperatorResolved(opts, targetFrame, scope.frames, new Map(), scope);
8303
- }
8304
- async screenshotForOperatorResolved(opts, targetFrame, framesBefore, sealedLocators = new Map(), scope = { frames: [...framesBefore], strictFrames: new Set(framesBefore), clip: null }, beforeCapture) {
8305
- if (!this.page)
8306
- throw new Error("Browser not started");
8307
- const page = this.page;
8308
- const extraRedactionSelectors = opts.extraRedactionSelectors ?? [];
8309
- const before = await this.collectOperatorScreenshotMask(framesBefore, extraRedactionSelectors, sealedLocators, scope.clip, scope.strictFrames);
8310
- const { rectangles, redactedCount, signature } = before;
8311
- let recheck;
8312
- try {
8313
- // caret:"initial" skips Playwright's default caret-hiding pass, which
8314
- // writes (and restores) caret-color on every editable element's inline
8315
- // style — this capture must leave element styles untouched.
8316
- let buffer;
8317
- let origin = { x: 0, y: 0 };
8318
- let captureSize;
8319
- const cdp = await page.context().newCDPSession(page);
8320
- try {
8321
- if (targetFrame !== null && targetFrame !== page.mainFrame()) {
8322
- const handle = await targetFrame.frameElement();
8323
- try {
8324
- const box = await handle.boundingBox();
8325
- if (box === null)
8326
- throw new Error("screenshot_redaction_unresolved");
8327
- if (scope.clip === null ||
8328
- box.x !== scope.clip.x ||
8329
- box.y !== scope.clip.y ||
8330
- box.width !== scope.clip.width ||
8331
- box.height !== scope.clip.height) {
8332
- throw new Error("screenshot_redaction_unresolved");
8333
- }
8334
- const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
8335
- origin = { x: box.x, y: box.y };
8336
- captureSize = { width: box.width, height: box.height };
8337
- await beforeCapture?.();
8338
- const result = await cdp.send("Page.captureScreenshot", {
8339
- format: "jpeg",
8340
- quality: 80,
8341
- fromSurface: true,
8342
- captureBeyondViewport: true,
8343
- clip: {
8344
- x: box.x + scroll.x,
8345
- y: box.y + scroll.y,
8346
- width: box.width,
8347
- height: box.height,
8348
- scale: 1,
8349
- },
8350
- });
8351
- buffer = Buffer.from(result.data, "base64");
8352
- }
8353
- finally {
8354
- await handle.dispose().catch(() => undefined);
8355
- }
8356
- }
8357
- else if (opts.fullPage === true) {
8358
- const dimensions = await page.evaluate(() => ({
8359
- origin: { x: -window.scrollX, y: -window.scrollY },
8360
- size: {
8361
- width: document.documentElement.scrollWidth,
8362
- height: document.documentElement.scrollHeight,
8363
- },
8364
- }));
8365
- origin = dimensions.origin;
8366
- captureSize = dimensions.size;
8367
- await beforeCapture?.();
7928
+ const box = await handle.boundingBox();
7929
+ if (box === null)
7930
+ throw new Error("screenshot_frame_not_visible");
7931
+ const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
8368
7932
  const result = await cdp.send("Page.captureScreenshot", {
8369
7933
  format: "jpeg",
8370
7934
  quality: 80,
8371
7935
  fromSurface: true,
8372
7936
  captureBeyondViewport: true,
8373
7937
  clip: {
8374
- x: 0,
8375
- y: 0,
8376
- width: dimensions.size.width,
8377
- height: dimensions.size.height,
7938
+ x: box.x + scroll.x,
7939
+ y: box.y + scroll.y,
7940
+ width: box.width,
7941
+ height: box.height,
8378
7942
  scale: 1,
8379
7943
  },
8380
7944
  });
8381
- buffer = Buffer.from(result.data, "base64");
7945
+ base64 = result.data;
8382
7946
  }
8383
- else {
8384
- const viewport = page.viewportSize();
8385
- if (viewport === null)
8386
- throw new Error("screenshot_redaction_unresolved");
8387
- captureSize = viewport;
8388
- await beforeCapture?.();
8389
- const result = await cdp.send("Page.captureScreenshot", {
8390
- format: "jpeg",
8391
- quality: 80,
8392
- fromSurface: true,
8393
- });
8394
- buffer = Buffer.from(result.data, "base64");
7947
+ finally {
7948
+ await handle.dispose().catch(() => undefined);
8395
7949
  }
8396
7950
  }
8397
- finally {
8398
- await cdp.detach().catch(() => undefined);
8399
- }
8400
- // Stability guard: the mask set was fixed before a capture that can take
8401
- // seconds. If the page grew another matching field or frame meanwhile,
8402
- // the image may hold pixels no mask covered — re-run the same collection
8403
- // and discard the image unless the frame set and per-frame redaction
8404
- // signature are unchanged.
8405
- const scopeAfter = await this.operatorScreenshotCaptureScope(targetFrame);
8406
- const framesAfter = scopeAfter.frames;
8407
- let stable = JSON.stringify(scopeAfter.clip) === JSON.stringify(scope.clip) &&
8408
- framesAfter.length === framesBefore.length &&
8409
- framesBefore.every((frame, index) => framesAfter[index] === frame);
8410
- if (stable) {
8411
- try {
8412
- recheck = await this.collectOperatorScreenshotMask(framesAfter, extraRedactionSelectors, sealedLocators, scope.clip, scope.strictFrames);
8413
- stable =
8414
- recheck.signature === signature && recheck.handles.length === before.handles.length;
8415
- if (stable) {
8416
- for (let index = 0; index < before.handles.length; index += 1) {
8417
- if (!(await recheck.handles[index].evaluate((el, expected) => el === expected, before.handles[index]))) {
8418
- stable = false;
8419
- break;
8420
- }
8421
- }
8422
- }
8423
- }
8424
- catch {
8425
- stable = false;
8426
- }
7951
+ else if (opts.fullPage === true) {
7952
+ const size = await page.evaluate(() => ({
7953
+ width: document.documentElement.scrollWidth,
7954
+ height: document.documentElement.scrollHeight,
7955
+ }));
7956
+ const result = await cdp.send("Page.captureScreenshot", {
7957
+ format: "jpeg",
7958
+ quality: 80,
7959
+ fromSurface: true,
7960
+ captureBeyondViewport: true,
7961
+ clip: { x: 0, y: 0, width: size.width, height: size.height, scale: 1 },
7962
+ });
7963
+ base64 = result.data;
7964
+ }
7965
+ else {
7966
+ const result = await cdp.send("Page.captureScreenshot", {
7967
+ format: "jpeg",
7968
+ quality: 80,
7969
+ fromSurface: true,
7970
+ });
7971
+ base64 = result.data;
8427
7972
  }
8428
- if (!stable)
8429
- throw new Error("screenshot_redaction_unstable");
8430
- const base64 = await this.redactOperatorScreenshot(buffer, rectangles, origin, captureSize);
8431
7973
  return {
8432
7974
  base64,
8433
7975
  frameUrl: targetFrame?.url() ?? null,
8434
7976
  frameCount: page.frames().length,
8435
- redactedCount,
8436
7977
  };
8437
7978
  }
8438
7979
  finally {
8439
- await Promise.all([
8440
- ...before.handles.map(async (handle) => await handle.dispose().catch(() => undefined)),
8441
- ...(recheck?.handles ?? []).map(async (handle) => await handle.dispose().catch(() => undefined)),
8442
- ]);
7980
+ await cdp.detach().catch(() => undefined);
8443
7981
  }
8444
7982
  }
8445
7983
  async getState() {
@@ -8489,6 +8027,32 @@ export class BrowserController {
8489
8027
  throw new Error("Browser not started");
8490
8028
  return await this.page.evaluate(extractObservationVisibleText);
8491
8029
  }
8030
+ /**
8031
+ * Tiny structural source for compact V2. The raw values never leave the
8032
+ * provision session: compact-observation-v2 applies its allowlist seal
8033
+ * before the result is stored, delta'd, or emitted.
8034
+ */
8035
+ async extractObservationSemantics() {
8036
+ if (!this.page)
8037
+ throw new Error("Browser not started");
8038
+ return await this.page.evaluate(() => {
8039
+ const visible = (element) => {
8040
+ const html = element;
8041
+ const style = window.getComputedStyle(html);
8042
+ const rect = html.getBoundingClientRect();
8043
+ return (style.display !== "none" &&
8044
+ style.visibility !== "hidden" &&
8045
+ rect.width > 0 &&
8046
+ rect.height > 0);
8047
+ };
8048
+ const headings = Array.from(document.querySelectorAll("h1,h2"))
8049
+ .filter(visible)
8050
+ .map((element) => (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 160))
8051
+ .filter(Boolean)
8052
+ .slice(0, 2);
8053
+ return { title: document.title.slice(0, 160), headings };
8054
+ });
8055
+ }
8492
8056
  async readCheckoutSummary(fallbackCurrency) {
8493
8057
  if (!this.page)
8494
8058
  throw new Error("Browser not started");
@@ -8783,6 +8347,28 @@ export class BrowserController {
8783
8347
  });
8784
8348
  return includeDetails ? items : items.map(({ title, quantity }) => ({ title, quantity }));
8785
8349
  }
8350
+ // Deterministic cart-normalize primitive: Shopify's standard Ajax Cart API
8351
+ // exposes POST /cart/clear.js on every storefront's own origin — no DOM
8352
+ // markup dependency, so it works regardless of theme. This is how an
8353
+ // operator reaches a KNOWN (empty) cart quantity before a fresh cart_add,
8354
+ // rather than accumulating quantity across separate operate_start sessions
8355
+ // on the shared persistent profile.
8356
+ async clearCart() {
8357
+ if (!this.page)
8358
+ throw new Error("Browser not started");
8359
+ return await this.page.evaluate(async () => {
8360
+ try {
8361
+ const response = await fetch("/cart/clear.js", {
8362
+ method: "POST",
8363
+ headers: { "Content-Type": "application/json" },
8364
+ });
8365
+ return response.ok;
8366
+ }
8367
+ catch {
8368
+ return false;
8369
+ }
8370
+ });
8371
+ }
8786
8372
  async readSettledCheckoutReviewSummary(fallbackCurrency, timeoutMs = 12_000) {
8787
8373
  if (!this.page)
8788
8374
  throw new Error("Browser not started");
@@ -8843,10 +8429,10 @@ export class BrowserController {
8843
8429
  }
8844
8430
  }
8845
8431
  // The first frame that actually renders a visible card (PAN) input, or null.
8846
- async panFieldFrame() {
8432
+ async panFieldFrame(frames) {
8847
8433
  if (!this.page)
8848
8434
  return null;
8849
- for (const frame of this.page.frames()) {
8435
+ for (const frame of frames ?? this.page.frames()) {
8850
8436
  const locator = frame.locator(CHECKOUT_PAN_FIELD_SELECTORS);
8851
8437
  const count = await locator.count().catch(() => 0);
8852
8438
  for (let i = 0; i < count; i += 1) {
@@ -8859,7 +8445,7 @@ export class BrowserController {
8859
8445
  }
8860
8446
  return null;
8861
8447
  }
8862
- // Bounded wait for a PAN field to appear anywhere on the page. A
8448
+ // Bounded wait for a PAN field to appear in caller-eligible frames. A
8863
8449
  // single-page checkout's card entry can live in a cross-origin PCI iframe
8864
8450
  // (e.g. Shopify's checkout.pci.shopifyinc.com) that mounts only after the
8865
8451
  // payment section itself renders — later than the total becomes readable,
@@ -8867,7 +8453,7 @@ export class BrowserController {
8867
8453
  // fillAndSubmitCheckout/fillCheckoutCardFields used to take one frames()
8868
8454
  // snapshot at call time; a frame that hadn't mounted YET at that exact
8869
8455
  // instant made a genuinely fillable checkout fail closed.
8870
- async waitForPanField(timeoutMs) {
8456
+ async waitForPanField(timeoutMs, frameAllowed = () => true) {
8871
8457
  if (!this.page)
8872
8458
  return;
8873
8459
  const deadline = Date.now() + timeoutMs;
@@ -8877,14 +8463,108 @@ export class BrowserController {
8877
8463
  // CHECKOUT_PAN_FIELD_SELECTORS once stamped — without this,
8878
8464
  // panFieldFrame() below can never match and every call here burns its
8879
8465
  // full timeoutMs even though the field was on the page from the start.
8880
- await this.stampJapaneseCardLabelFields(this.page.frames());
8881
- if ((await this.panFieldFrame()) !== null)
8466
+ const frames = this.page.frames().filter(frameAllowed);
8467
+ await this.stampJapaneseCardLabelFields(frames);
8468
+ if ((await this.panFieldFrame(frames)) !== null)
8882
8469
  return;
8883
8470
  if (Date.now() >= deadline)
8884
8471
  return;
8885
8472
  await this.page.waitForTimeout(200).catch(() => undefined);
8886
8473
  }
8887
8474
  }
8475
+ // Split checkout waits are event-driven: a trusted hosted-field frame can
8476
+ // attach as about:blank and only later navigate to its processor URL. Watch
8477
+ // both lifecycle events and then wait inside that exact frame for the PAN.
8478
+ // A settled page with a PAN only in excluded frames returns immediately so
8479
+ // the caller can produce its existing fail-closed frame-origin refusal.
8480
+ async waitForRecognizedPanField(pageUrl, deadline) {
8481
+ if (!this.page)
8482
+ return;
8483
+ const page = this.page;
8484
+ const frameAllowed = (frame) => frame === page.mainFrame() || recognizedPaymentProviderFrame(frame.url(), pageUrl);
8485
+ const remaining = () => deadline === undefined ? 0 : Math.max(0, deadline - Date.now());
8486
+ let done = false;
8487
+ let resolveDone;
8488
+ const complete = () => {
8489
+ if (done)
8490
+ return;
8491
+ done = true;
8492
+ resolveDone();
8493
+ };
8494
+ const completed = new Promise((resolve) => {
8495
+ resolveDone = resolve;
8496
+ });
8497
+ const observeTrustedFrame = (frame) => {
8498
+ if (done || !frameAllowed(frame))
8499
+ return;
8500
+ void (async () => {
8501
+ // JP checkouts can expose their PAN only through an associated label,
8502
+ // so normalize the trusted frame before starting its selector wait.
8503
+ // This preserves the existing conservative label-to-control contract
8504
+ // while never evaluating or mutating an excluded payment frame.
8505
+ await this.stampJapaneseCardLabelFields([frame]);
8506
+ if ((await this.panFieldFrame([frame])) !== null) {
8507
+ complete();
8508
+ return;
8509
+ }
8510
+ await frame
8511
+ .waitForSelector(CHECKOUT_PAN_FIELD_SELECTORS, {
8512
+ state: "visible",
8513
+ timeout: remaining(),
8514
+ })
8515
+ .then(() => complete())
8516
+ .catch(() => undefined);
8517
+ })();
8518
+ };
8519
+ const settleOrRefuse = () => {
8520
+ void page
8521
+ .waitForLoadState("networkidle", { timeout: remaining() })
8522
+ .then(async () => {
8523
+ if (done)
8524
+ return;
8525
+ const frames = page.frames();
8526
+ const trustedFrames = frames.filter(frameAllowed);
8527
+ await this.stampJapaneseCardLabelFields(trustedFrames);
8528
+ if ((await this.panFieldFrame(trustedFrames)) !== null) {
8529
+ complete();
8530
+ return;
8531
+ }
8532
+ // Do not reject while a recognized provider frame is still live:
8533
+ // its PAN may appear after the provider's own hydration work. The
8534
+ // deadline governs that wait; there is no local timing heuristic.
8535
+ if (frames.some((frame) => frame !== page.mainFrame() && frameAllowed(frame)))
8536
+ return;
8537
+ complete();
8538
+ })
8539
+ .catch(() => undefined);
8540
+ };
8541
+ const onFrameLifecycle = (frame) => {
8542
+ observeTrustedFrame(frame);
8543
+ settleOrRefuse();
8544
+ };
8545
+ page.on("frameattached", onFrameLifecycle);
8546
+ page.on("framenavigated", onFrameLifecycle);
8547
+ for (const frame of page.frames())
8548
+ observeTrustedFrame(frame);
8549
+ settleOrRefuse();
8550
+ let deadlineTimer;
8551
+ if (deadline !== undefined) {
8552
+ const timeout = remaining();
8553
+ if (timeout <= 0)
8554
+ complete();
8555
+ else
8556
+ deadlineTimer = setTimeout(complete, timeout);
8557
+ }
8558
+ try {
8559
+ await completed;
8560
+ }
8561
+ finally {
8562
+ if (deadlineTimer !== undefined)
8563
+ clearTimeout(deadlineTimer);
8564
+ page.off("frameattached", onFrameLifecycle);
8565
+ page.off("framenavigated", onFrameLifecycle);
8566
+ }
8567
+ }
8888
8568
  // Normalizes conservative PAN name/id conventions and scans
8889
8569
  // dt/th/label/"table-label" elements for カード番号 / カード名義 /
8890
8570
  // セキュリティコード and stamps the associated single text input with
@@ -9966,7 +9646,7 @@ export class BrowserController {
9966
9646
  this.rememberPaymentInstrumentExpectation(card);
9967
9647
  primary = {
9968
9648
  kind: "outcome",
9969
- value: await this.submitFilledCheckoutInScope(cardGroup, options.onSubmitDispatched),
9649
+ value: await this.submitFilledCheckoutInScope(cardGroup, options.onSubmitDispatched, options.beforeSubmitDispatch),
9970
9650
  };
9971
9651
  }
9972
9652
  catch (error) {
@@ -10000,7 +9680,7 @@ export class BrowserController {
10000
9680
  // left behind. On success the filled values STAY in the page (the site
10001
9681
  // needs them at its confirm step) marked data-ts-sealed-payment, which
10002
9682
  // extractInteractiveElements reports as sealed so observations mask them.
10003
- async fillCheckoutCardFields(card) {
9683
+ async fillCheckoutCardFields(card, options = {}) {
10004
9684
  if (!this.page)
10005
9685
  throw new Error("Browser not started");
10006
9686
  this.checkoutCardGroupScope = undefined;
@@ -10009,11 +9689,10 @@ export class BrowserController {
10009
9689
  if (!recognizedPaymentProviderFrame(pageUrl, pageUrl)) {
10010
9690
  throw new Error("payment_checkout_https_required");
10011
9691
  }
10012
- // Same late-mount tolerance as fillAndSubmitCheckout: wait for a PAN
10013
- // field to appear anywhere before taking the trusted-frame snapshot below
10014
- // (which still restricts the actual fill to recognized frames — this only
10015
- // decides WHEN to look, never WHERE it's allowed to write).
10016
- await this.waitForPanField(10_000);
9692
+ await this.waitForRecognizedPanField(pageUrl, options.deadline);
9693
+ if (options.deadline !== undefined && Date.now() >= options.deadline) {
9694
+ throw new Error("payment_approval_expired");
9695
+ }
10017
9696
  const allowed = page
10018
9697
  .frames()
10019
9698
  .filter((frame) => frame === page.mainFrame() || recognizedPaymentProviderFrame(frame.url(), pageUrl));
@@ -10206,30 +9885,7 @@ export class BrowserController {
10206
9885
  async submitFilledCheckout() {
10207
9886
  return await this.submitFilledCheckoutInScope(this.checkoutCardGroupScope);
10208
9887
  }
10209
- async ensureCheckoutSubmitDispatchBinding(page) {
10210
- if (this.checkoutSubmitDispatchBindingPages.has(page))
10211
- return;
10212
- await page.exposeBinding(this.checkoutSubmitDispatchBindingName, (source, payload) => {
10213
- if (payload === null || typeof payload !== "object")
10214
- return;
10215
- const candidate = payload;
10216
- if (typeof candidate.token !== "string" || typeof candidate.nonce !== "string")
10217
- return;
10218
- const waiter = this.checkoutSubmitDispatchWaiters.get(candidate.token);
10219
- if (waiter === undefined ||
10220
- waiter.frame !== source.frame ||
10221
- waiter.nonce !== candidate.nonce) {
10222
- return;
10223
- }
10224
- const snapshot = parseCheckoutOutcomeDispatchSnapshot(candidate.snapshot);
10225
- if (snapshot === null)
10226
- return;
10227
- waiter.resolve(snapshot);
10228
- waiter.report();
10229
- });
10230
- this.checkoutSubmitDispatchBindingPages.add(page);
10231
- }
10232
- async submitFilledCheckoutInScope(cardGroup, onSubmitDispatched) {
9888
+ async submitFilledCheckoutInScope(cardGroup, onSubmitDispatched, beforeSubmitDispatch) {
10233
9889
  if (!this.page)
10234
9890
  throw new Error("Browser not started");
10235
9891
  const savedCardSelection = await this.resolveCompetingSavedCardSelection();
@@ -10239,6 +9895,7 @@ export class BrowserController {
10239
9895
  let outcomeBaseline;
10240
9896
  this.checkoutOutcomeBaseline = undefined;
10241
9897
  let submitted = false;
9898
+ let clearSubmittedDispatchTracking = null;
10242
9899
  for (const frame of this.page.frames()) {
10243
9900
  const matches = frame.locator('button,input[type="submit"],[role="button"]');
10244
9901
  const count = Math.min(await matches.count().catch(() => 0), 100);
@@ -10288,10 +9945,11 @@ export class BrowserController {
10288
9945
  if (!CHECKOUT_SUBMIT_LABEL_RE.test(label))
10289
9946
  continue;
10290
9947
  const dispatchToken = `ts-payment-submit-${this.checkoutSubmitSequence++}`;
10291
- const dispatchNonce = randomUUID();
10292
- const dispatchBaselineStorageKey = `__trusty_squire_payment_baseline_${dispatchToken}`;
10293
9948
  const preDispatchFrameUrls = this.page.frames().map((pageFrame) => pageFrame.url());
10294
- await this.ensureCheckoutSubmitDispatchBinding(this.page);
9949
+ const clickOnlyOutcomeBaseline = checkoutOutcomeBaselineFromDispatchSnapshot({
9950
+ url: this.page.url(),
9951
+ urls: preDispatchFrameUrls,
9952
+ });
10295
9953
  let submitDispatchedReported = false;
10296
9954
  const reportSubmitDispatched = () => {
10297
9955
  if (submitDispatchedReported)
@@ -10299,152 +9957,135 @@ export class BrowserController {
10299
9957
  onSubmitDispatched?.();
10300
9958
  submitDispatchedReported = true;
10301
9959
  };
10302
- let resolveBoundDispatch = (_snapshot) => undefined;
10303
- const boundDispatch = new Promise((resolve) => {
10304
- resolveBoundDispatch = resolve;
10305
- });
10306
- this.checkoutSubmitDispatchWaiters.set(dispatchToken, {
10307
- frame,
10308
- nonce: dispatchNonce,
10309
- resolve: resolveBoundDispatch,
10310
- report: reportSubmitDispatched,
10311
- });
10312
9960
  const dispatchTrackingInstalled = await candidate
10313
- .evaluate((element, options) => {
10314
- const { baselineStorageKey, bindingName, frameUrls, nonce, pageUrl, token } = options;
9961
+ .evaluate((element, token) => {
10315
9962
  const stateWindow = window;
10316
9963
  const tracked = element;
10317
- if (tracked.__tsPaymentSubmitDispatchListener !== undefined) {
10318
- element.removeEventListener("click", tracked.__tsPaymentSubmitDispatchListener, true);
9964
+ const priorTracking = tracked.__tsPaymentSubmitDispatchListeners;
9965
+ if (priorTracking !== undefined) {
9966
+ for (const registration of priorTracking) {
9967
+ registration.target.removeEventListener(registration.event, registration.listener, registration.capture);
9968
+ }
10319
9969
  }
10320
9970
  stateWindow.__trustySquirePaymentSubmitDispatch = {
10321
9971
  token,
10322
- dispatched: false,
9972
+ validationBlocked: false,
10323
9973
  };
10324
- const listener = () => {
9974
+ const form = element instanceof HTMLButtonElement || element instanceof HTMLInputElement
9975
+ ? element.form
9976
+ : element.closest("form");
9977
+ const submitTargets = form !== null ? [form] : Array.from(document.forms);
9978
+ const registrations = [];
9979
+ const invalidListener = () => {
10325
9980
  const state = stateWindow.__trustySquirePaymentSubmitDispatch;
10326
- if (state?.token !== token)
10327
- return;
10328
- try {
10329
- const merchantWindow = window.top;
10330
- if (merchantWindow === null)
10331
- return;
10332
- const urls = new Set(frameUrls);
10333
- const collectFrameUrls = (currentWindow) => {
10334
- try {
10335
- urls.add(currentWindow.location.href);
10336
- for (let index = 0; index < currentWindow.frames.length; index += 1) {
10337
- collectFrameUrls(currentWindow.frames[index]);
10338
- }
10339
- }
10340
- catch (error) {
10341
- void error;
10342
- }
10343
- };
10344
- collectFrameUrls(merchantWindow);
10345
- const snapshot = {
10346
- url: pageUrl,
10347
- urls: [...urls],
10348
- };
10349
- const dispatchBinding = window[bindingName];
10350
- if (typeof dispatchBinding === "function") {
10351
- void dispatchBinding({ token, nonce, snapshot });
10352
- }
10353
- const baselineWindow = merchantWindow;
10354
- baselineWindow.__trustySquirePaymentDispatchBaselines ??= {};
10355
- baselineWindow.__trustySquirePaymentDispatchBaselines[token] = snapshot;
10356
- try {
10357
- merchantWindow.sessionStorage.setItem(baselineStorageKey, JSON.stringify(snapshot));
10358
- }
10359
- catch (error) {
10360
- void error;
10361
- }
10362
- }
10363
- finally {
10364
- state.dispatched = true;
10365
- }
9981
+ if (state?.token === token)
9982
+ state.validationBlocked = true;
10366
9983
  };
10367
- tracked.__tsPaymentSubmitDispatchListener = listener;
10368
- element.addEventListener("click", listener, { capture: true, once: true });
10369
- }, {
10370
- baselineStorageKey: dispatchBaselineStorageKey,
10371
- bindingName: this.checkoutSubmitDispatchBindingName,
10372
- frameUrls: preDispatchFrameUrls,
10373
- nonce: dispatchNonce,
10374
- pageUrl: this.page.url(),
10375
- token: dispatchToken,
10376
- })
9984
+ registrations.push(...submitTargets.map((target) => ({
9985
+ capture: true,
9986
+ event: "invalid",
9987
+ listener: invalidListener,
9988
+ target,
9989
+ })));
9990
+ tracked.__tsPaymentSubmitDispatchListeners = registrations;
9991
+ for (const registration of registrations) {
9992
+ registration.target.addEventListener(registration.event, registration.listener, {
9993
+ capture: registration.capture,
9994
+ once: true,
9995
+ });
9996
+ }
9997
+ }, dispatchToken)
10377
9998
  .then(() => true)
10378
9999
  .catch(() => false);
10379
10000
  if (!dispatchTrackingInstalled) {
10380
- this.checkoutSubmitDispatchWaiters.delete(dispatchToken);
10381
10001
  continue;
10382
10002
  }
10383
- const readDispatchOutcomeBaseline = async () => {
10384
- const snapshot = await this.page.mainFrame()
10385
- .evaluate(({ baselineStorageKey, token }) => {
10386
- const baselineWindow = window;
10387
- let captured = baselineWindow.__trustySquirePaymentDispatchBaselines?.[token] ?? null;
10388
- if (captured !== null) {
10389
- delete baselineWindow.__trustySquirePaymentDispatchBaselines?.[token];
10390
- }
10391
- if (captured === null) {
10392
- try {
10393
- const stored = sessionStorage.getItem(baselineStorageKey);
10394
- if (stored !== null) {
10395
- captured = JSON.parse(stored);
10396
- }
10397
- }
10398
- catch {
10399
- captured = null;
10400
- }
10401
- }
10402
- try {
10403
- sessionStorage.removeItem(baselineStorageKey);
10003
+ let paymentRequestTrackingArmed = false;
10004
+ let concretePaymentRequestObserved = false;
10005
+ let resolveConcretePaymentRequest = () => undefined;
10006
+ const concretePaymentRequest = new Promise((resolve) => {
10007
+ resolveConcretePaymentRequest = resolve;
10008
+ });
10009
+ let navigationObserved = false;
10010
+ let navigationTerminalObserved = false;
10011
+ let navigationThreeDsObserved = false;
10012
+ let resolveNavigationOutcome = () => undefined;
10013
+ const navigationOutcome = new Promise((resolve) => {
10014
+ resolveNavigationOutcome = resolve;
10015
+ });
10016
+ const paymentRequestListener = (request) => {
10017
+ const activePage = this.page;
10018
+ if (!paymentRequestTrackingArmed || activePage === null)
10019
+ return;
10020
+ let sourceFrame;
10021
+ try {
10022
+ sourceFrame = request.frame();
10023
+ }
10024
+ catch {
10025
+ return;
10026
+ }
10027
+ if (sourceFrame !== frame && sourceFrame !== activePage.mainFrame())
10028
+ return;
10029
+ if (!isCheckoutPaymentRequest(request))
10030
+ return;
10031
+ concretePaymentRequestObserved = true;
10032
+ resolveConcretePaymentRequest();
10033
+ };
10034
+ const navigationListener = () => {
10035
+ if (!paymentRequestTrackingArmed || this.page === null)
10036
+ return;
10037
+ navigationObserved = true;
10038
+ void (async () => {
10039
+ if (await this.hasConfirmedCheckoutOutcome(clickOnlyOutcomeBaseline)) {
10040
+ navigationTerminalObserved = true;
10041
+ resolveNavigationOutcome();
10042
+ return;
10404
10043
  }
10405
- catch (error) {
10406
- void error;
10044
+ const challenge = await this.detectThreeDsChallenge().catch(() => undefined);
10045
+ if (challenge?.three_ds_required === true) {
10046
+ navigationThreeDsObserved = true;
10047
+ resolveNavigationOutcome();
10407
10048
  }
10408
- return captured;
10409
- }, { baselineStorageKey: dispatchBaselineStorageKey, token: dispatchToken })
10410
- .catch(() => null);
10411
- const parsedSnapshot = parseCheckoutOutcomeDispatchSnapshot(snapshot);
10412
- return parsedSnapshot === null
10413
- ? null
10414
- : checkoutOutcomeBaselineFromDispatchSnapshot(parsedSnapshot);
10049
+ })();
10415
10050
  };
10416
- const readBoundDispatchOutcomeBaseline = async () => {
10051
+ this.page.on("request", paymentRequestListener);
10052
+ this.page.on("framenavigated", navigationListener);
10053
+ const waitForDispatchEvidence = async () => {
10417
10054
  let timer;
10418
- const snapshot = await Promise.race([
10419
- boundDispatch,
10055
+ await Promise.race([
10056
+ concretePaymentRequest,
10057
+ navigationOutcome,
10420
10058
  new Promise((resolve) => {
10421
- timer = setTimeout(() => resolve(null), 250);
10059
+ timer = setTimeout(resolve, CHECKOUT_PAYMENT_REQUEST_OBSERVATION_MS);
10422
10060
  }),
10423
10061
  ]);
10424
10062
  if (timer !== undefined)
10425
10063
  clearTimeout(timer);
10426
- return snapshot === null ? null : checkoutOutcomeBaselineFromDispatchSnapshot(snapshot);
10427
10064
  };
10428
10065
  const readDispatchState = async () => await frame
10429
10066
  .evaluate((token) => {
10430
10067
  const stateWindow = window;
10431
10068
  const state = stateWindow.__trustySquirePaymentSubmitDispatch;
10432
10069
  return {
10433
- sameDocument: state?.token === token,
10434
- dispatched: state?.token === token && state.dispatched,
10070
+ validationBlocked: state?.token === token && state.validationBlocked,
10435
10071
  };
10436
10072
  }, dispatchToken)
10437
10073
  .catch(() => null);
10438
10074
  const clearDispatchTracking = async () => {
10439
- this.checkoutSubmitDispatchWaiters.delete(dispatchToken);
10075
+ paymentRequestTrackingArmed = false;
10076
+ this.page?.off("request", paymentRequestListener);
10077
+ this.page?.off("framenavigated", navigationListener);
10440
10078
  await candidate
10441
10079
  .evaluate((element) => {
10442
10080
  const tracked = element;
10443
- if (tracked.__tsPaymentSubmitDispatchListener !== undefined) {
10444
- element.removeEventListener("click", tracked.__tsPaymentSubmitDispatchListener, true);
10445
- delete tracked.__tsPaymentSubmitDispatchListener;
10081
+ const tracking = tracked.__tsPaymentSubmitDispatchListeners;
10082
+ if (tracking !== undefined) {
10083
+ for (const registration of tracking) {
10084
+ registration.target.removeEventListener(registration.event, registration.listener, registration.capture);
10085
+ }
10086
+ delete tracked.__tsPaymentSubmitDispatchListeners;
10446
10087
  }
10447
- })
10088
+ }, undefined, { timeout: 250 })
10448
10089
  .catch(() => undefined);
10449
10090
  await frame
10450
10091
  .evaluate((token) => {
@@ -10454,7 +10095,6 @@ export class BrowserController {
10454
10095
  }
10455
10096
  }, dispatchToken)
10456
10097
  .catch(() => undefined);
10457
- await readDispatchOutcomeBaseline();
10458
10098
  };
10459
10099
  // Money-fence boundary: the async pay-button scan above (visibility/
10460
10100
  // enabled/ownership/label checks, dispatch-tracking install) is a real
@@ -10473,26 +10113,56 @@ export class BrowserController {
10473
10113
  }
10474
10114
  capturedBaseline = await runCaptureConfirmedPaymentSubmit({
10475
10115
  click: async (markInputDispatchPossible) => {
10116
+ let remainingMs;
10117
+ try {
10118
+ remainingMs = beforeSubmitDispatch?.();
10119
+ }
10120
+ catch (error) {
10121
+ throw new BrowserClickDispatchError("not_dispatched", error);
10122
+ }
10123
+ paymentRequestTrackingArmed = true;
10476
10124
  markInputDispatchPossible();
10477
- await candidate.click({ noWaitAfter: true });
10125
+ await candidate.click({
10126
+ noWaitAfter: true,
10127
+ ...(typeof remainingMs === "number"
10128
+ ? { timeout: Math.max(1, Math.ceil(remainingMs)) }
10129
+ : {}),
10130
+ });
10478
10131
  },
10479
10132
  readEvidence: async () => {
10480
- const documentBaseline = await readDispatchOutcomeBaseline();
10481
- const baseline = documentBaseline ?? (await readBoundDispatchOutcomeBaseline());
10482
- const dispatchState = baseline === null ? await readDispatchState() : null;
10133
+ await waitForDispatchEvidence();
10134
+ const dispatchState = await readDispatchState();
10135
+ const clickOnlyOutcomeConfirmed = navigationTerminalObserved ||
10136
+ (await this.hasConfirmedCheckoutOutcome(clickOnlyOutcomeBaseline));
10137
+ const clickOnlyThreeDsObserved = navigationObserved &&
10138
+ (navigationThreeDsObserved ||
10139
+ (await this.detectThreeDsChallenge().catch(() => undefined))
10140
+ ?.three_ds_required === true);
10141
+ const clickOnlyDispatchObserved = (concretePaymentRequestObserved && dispatchState?.validationBlocked !== true) ||
10142
+ clickOnlyOutcomeConfirmed ||
10143
+ clickOnlyThreeDsObserved;
10483
10144
  return {
10484
- baseline,
10485
- dispatched: baseline !== null || dispatchState?.dispatched === true,
10145
+ baseline: clickOnlyDispatchObserved ? clickOnlyOutcomeBaseline : null,
10146
+ dispatched: clickOnlyDispatchObserved,
10486
10147
  };
10487
10148
  },
10488
10149
  clear: async () => undefined,
10489
10150
  onSubmitDispatched: reportSubmitDispatched,
10490
10151
  });
10152
+ clearSubmittedDispatchTracking = clearDispatchTracking;
10491
10153
  }
10492
- finally {
10154
+ catch (error) {
10493
10155
  await clearDispatchTracking();
10156
+ throw error;
10157
+ }
10158
+ try {
10159
+ outcomeBaseline = capturedBaseline ?? (await this.captureCheckoutOutcomeBaseline());
10160
+ }
10161
+ catch (error) {
10162
+ await clearSubmittedDispatchTracking?.();
10163
+ clearSubmittedDispatchTracking = null;
10164
+ throw error;
10494
10165
  }
10495
- outcomeBaseline = capturedBaseline ?? (await this.captureCheckoutOutcomeBaseline());
10496
10166
  this.checkoutOutcomeBaseline = outcomeBaseline;
10497
10167
  submitted = true;
10498
10168
  break;
@@ -10500,8 +10170,10 @@ export class BrowserController {
10500
10170
  if (submitted)
10501
10171
  break;
10502
10172
  }
10503
- if (!submitted || outcomeBaseline === undefined)
10173
+ if (!submitted || outcomeBaseline === undefined) {
10174
+ await clearSubmittedDispatchTracking?.();
10504
10175
  throw new Error("payment_submit_not_found");
10176
+ }
10505
10177
  try {
10506
10178
  const challengeDeadline = Date.now() + 15_000;
10507
10179
  while (Date.now() < challengeDeadline) {
@@ -10509,17 +10181,23 @@ export class BrowserController {
10509
10181
  return { three_ds_required: false, order_confirmed: true };
10510
10182
  }
10511
10183
  const challenge = await this.detectThreeDsChallenge();
10512
- if (challenge.three_ds_required)
10184
+ if (challenge.three_ds_required) {
10513
10185
  return challenge;
10186
+ }
10514
10187
  await this.page.waitForTimeout(250).catch(() => undefined);
10515
10188
  }
10516
10189
  return { three_ds_required: false, order_confirmed: false };
10517
10190
  }
10518
10191
  catch (error) {
10519
- if (error instanceof PaymentSubmitOutcomeUnknownError)
10192
+ if (error instanceof PaymentSubmitOutcomeUnknownError ||
10193
+ error instanceof BrowserClickDispatchError) {
10520
10194
  throw error;
10195
+ }
10521
10196
  throw new PaymentSubmitOutcomeUnknownError();
10522
10197
  }
10198
+ finally {
10199
+ await clearSubmittedDispatchTracking?.();
10200
+ }
10523
10201
  }
10524
10202
  async clearSealedPaymentFields() {
10525
10203
  this.checkoutCardGroupScope = undefined;
@@ -10981,9 +10659,23 @@ export class BrowserController {
10981
10659
  catch {
10982
10660
  sameCheckoutOrigin = current.url === baseline.url;
10983
10661
  }
10984
- return (sameCheckoutOrigin &&
10662
+ const newTerminalOrderIdentity = sameCheckoutOrigin &&
10985
10663
  current.terminalUrlIdentity !== null &&
10986
- !baseline.orderUrlIdentities.includes(current.terminalUrlIdentity));
10664
+ !baseline.orderUrlIdentities.includes(current.terminalUrlIdentity);
10665
+ if (newTerminalOrderIdentity)
10666
+ return true;
10667
+ if (!sameCheckoutOrigin ||
10668
+ current.url === baseline.url ||
10669
+ !isShopifyCheckoutThankYouRoute(current.url)) {
10670
+ return false;
10671
+ }
10672
+ return await this.page.mainFrame().evaluate(() => {
10673
+ const visibleText = document.body?.innerText ?? "";
10674
+ const confirmationNumber = /\bconfirmation\s*#\s*[a-z0-9][a-z0-9-]{3,}\b/i.test(visibleText);
10675
+ const confirmedOrder = /\byour order is confirmed\b/i.test(visibleText);
10676
+ const thankYouHeading = Array.from(document.querySelectorAll("h1, h2, [role=heading]")).some((element) => /\bthank you\b/i.test(element.textContent ?? ""));
10677
+ return confirmedOrder || (thankYouHeading && confirmationNumber);
10678
+ });
10987
10679
  }
10988
10680
  // Let the browser complete the challenge natively (including out-of-band
10989
10681
  // bank-app 3DS): just poll for the same terminal-order signal a plain
@@ -10996,11 +10688,14 @@ export class BrowserController {
10996
10688
  const failureText = /(?:payment|card|transaction) (?:was )?declined|authentication failed|could not be (?:authenticated|processed|completed)|(?:please )?try (?:a |another )?(?:different )?card|3-?d ?secure (?:failed|unsuccessful)/i;
10997
10689
  const deadline = Date.now() + Math.max(timeoutMs, 0);
10998
10690
  const mismatchAtEntry = this.observedPaymentInstrumentMismatch;
10691
+ let challengeObserved = false;
10999
10692
  while (true) {
11000
10693
  await this.page.bringToFront().catch(() => undefined);
11001
- await this.detectThreeDsChallenge().catch(() => undefined);
10694
+ const challenge = await this.detectThreeDsChallenge().catch(() => undefined);
10695
+ if (challenge?.three_ds_required === true)
10696
+ challengeObserved = true;
11002
10697
  if (mismatchAtEntry === undefined && this.observedPaymentInstrumentMismatch !== undefined) {
11003
- return "timeout";
10698
+ return challengeObserved ? "challenge_pending" : "timeout";
11004
10699
  }
11005
10700
  if (await this.hasConfirmedCheckoutOutcome(outcomeBaseline))
11006
10701
  return "succeeded";
@@ -11012,7 +10707,7 @@ export class BrowserController {
11012
10707
  return "failed";
11013
10708
  const remainingMs = deadline - Date.now();
11014
10709
  if (remainingMs <= 0)
11015
- return "timeout";
10710
+ return challengeObserved ? "challenge_pending" : "timeout";
11016
10711
  await this.page.waitForTimeout(Math.min(1_000, remainingMs)).catch(() => undefined);
11017
10712
  }
11018
10713
  }
@@ -12774,6 +12469,18 @@ export class BrowserController {
12774
12469
  el instanceof HTMLTextAreaElement ||
12775
12470
  el instanceof HTMLSelectElement;
12776
12471
  const regionFor = (el) => el.closest('[role="dialog"],dialog,[aria-modal="true"],nav,main,header,footer,aside,form,section,article');
12472
+ const regionIds = new Map();
12473
+ let nextRegionId = 1;
12474
+ const regionId = (region) => {
12475
+ if (region === null)
12476
+ return null;
12477
+ const existing = regionIds.get(region);
12478
+ if (existing !== undefined)
12479
+ return existing;
12480
+ const id = nextRegionId++;
12481
+ regionIds.set(region, id);
12482
+ return id;
12483
+ };
12777
12484
  const regionName = (region) => {
12778
12485
  if (region === null)
12779
12486
  return null;
@@ -13061,8 +12768,11 @@ export class BrowserController {
13061
12768
  // matches it and the OAuth-first scan picks it up.
13062
12769
  const isGoogleGSIIframe = el instanceof HTMLIFrameElement &&
13063
12770
  (el.getAttribute("src") ?? "").includes("accounts.google.com/gsi/button");
13064
- const container = regionName(regionFor(el));
12771
+ const region = regionFor(el);
12772
+ const container = regionName(region);
12773
+ const containerId = regionId(region);
13065
12774
  const inDialog = nearestModalRegion(el) !== null;
12775
+ const formId = regionId(el.closest("form"));
13066
12776
  const status = topmostStatus(el);
13067
12777
  const pathLabel = isGoogleGSIIframe
13068
12778
  ? "Continue with Google"
@@ -13128,6 +12838,8 @@ export class BrowserController {
13128
12838
  checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")
13129
12839
  ? el.checked
13130
12840
  : null,
12841
+ disabled: el.matches(":disabled") || el.getAttribute("aria-disabled") === "true" ? true : null,
12842
+ required: el.matches(":required") || el.getAttribute("aria-required") === "true" ? true : null,
13131
12843
  // For <select>: the currently-selected option's visible text
13132
12844
  // and a short list of available option labels. The combination
13133
12845
  // is how the planner detects the "React-defaulted dropdown"
@@ -13151,12 +12863,12 @@ export class BrowserController {
13151
12863
  ? clean(el.options[el.selectedIndex]?.textContent ?? null)
13152
12864
  : null,
13153
12865
  interactedThisRun: el.getAttribute("data-ts-touched") === "1",
13154
- sealed: el.getAttribute("data-ts-sealed-payment") === "1",
13155
- sealedOrdinal: Array.from(el.getRootNode().querySelectorAll("input,textarea,select,[contenteditable='true']")).indexOf(el),
13156
12866
  screenPath: `${container ?? "body:root"} > ${elementKind(el)}:` +
13157
12867
  slug(pathLabel, `${elementKind(el)}-${out.length}`),
13158
12868
  container,
13159
12869
  inDialog,
12870
+ containerId,
12871
+ formId,
13160
12872
  topmost: status.topmost,
13161
12873
  occludedBy: status.occludedBy,
13162
12874
  });
@@ -13496,20 +13208,16 @@ export class BrowserController {
13496
13208
  const frame = await handle.ownerFrame();
13497
13209
  if (frame === null)
13498
13210
  throw new Error("type target has no owning frame");
13499
- const sealedFieldKeys = sealed
13500
- ? await this.operatorScreenshotIdentityKeys(handle, frame)
13501
- : [];
13502
13211
  if (sealed) {
13503
13212
  await handle.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
13504
13213
  }
13505
13214
  if (!this.humanize) {
13506
13215
  await handle.fill(text);
13507
- return sealedFieldKeys;
13216
+ return;
13508
13217
  }
13509
13218
  await handle.click({ timeout: 8000 }).catch(() => undefined);
13510
13219
  await handle.fill("").catch(() => undefined);
13511
13220
  await handle.type(text, { delay: rand(40, 110) });
13512
- return sealedFieldKeys;
13513
13221
  }
13514
13222
  finally {
13515
13223
  await handle.dispose().catch(() => undefined);
@@ -13587,21 +13295,8 @@ export class BrowserController {
13587
13295
  const page = this.page;
13588
13296
  const mainRaw = await this.extractElementsFromContext(page);
13589
13297
  const mainGroups = assignCardRadioGroups(mainRaw.clusterMeta);
13590
- const mainDocumentIdentity = await this.sealedDocumentIdentity(page.mainFrame());
13591
13298
  const mainElements = mainRaw.out.map((e, i) => ({
13592
13299
  ...e,
13593
- sealedIdentityKeys: sealedElementSemanticKeys({
13594
- tag: e.tag,
13595
- type: e.type,
13596
- id: e.id,
13597
- name: e.name,
13598
- testId: e.testId,
13599
- labelText: e.labelText,
13600
- ariaLabel: e.ariaLabel,
13601
- placeholder: e.placeholder,
13602
- landmark: e.landmark,
13603
- ordinal: e.sealedOrdinal,
13604
- }).map((key) => `${mainDocumentIdentity}:${key}`),
13605
13300
  cardRadioGroup: mainGroups[i] ?? null,
13606
13301
  frameOrigin: null,
13607
13302
  frameUrl: null,
@@ -13633,23 +13328,10 @@ export class BrowserController {
13633
13328
  const raw = await this.extractElementsFromContext(frame);
13634
13329
  const security = await this.frameSecurity(frame);
13635
13330
  const frameOrigin = security.origin;
13636
- const frameDocumentIdentity = await this.sealedDocumentIdentity(frame);
13637
13331
  const groups = assignCardRadioGroups(raw.clusterMeta);
13638
13332
  for (const [i, e] of raw.out.entries()) {
13639
13333
  framedElements.push({
13640
13334
  ...e,
13641
- sealedIdentityKeys: sealedElementSemanticKeys({
13642
- tag: e.tag,
13643
- type: e.type,
13644
- id: e.id,
13645
- name: e.name,
13646
- testId: e.testId,
13647
- labelText: e.labelText,
13648
- ariaLabel: e.ariaLabel,
13649
- placeholder: e.placeholder,
13650
- landmark: e.landmark,
13651
- ordinal: e.sealedOrdinal,
13652
- }).map((key) => `${frameDocumentIdentity}:${key}`),
13653
13335
  cardRadioGroup: groups[i] ?? null,
13654
13336
  frameOrigin,
13655
13337
  frameUrl,
@@ -13725,7 +13407,6 @@ export class BrowserController {
13725
13407
  async startOAuth(selector) {
13726
13408
  if (!this.page || !this.context)
13727
13409
  throw new Error("Browser not started");
13728
- this.maybeAttachOAuthNetListener();
13729
13410
  if (!/accounts\.google\.com|github\.com\/login|login\.microsoftonline\.com/i.test(this.page.url())) {
13730
13411
  this.oauthProductPage = this.page;
13731
13412
  }
@@ -13762,30 +13443,46 @@ export class BrowserController {
13762
13443
  // clicks the product's already-observed OAuth control and waits for the
13763
13444
  // provider-owned window to finish; no credentials, frames, or consent scopes
13764
13445
  // are read or bypassed here.
13765
- async loginWithOAuth(selector, settleTimeoutMs = 12_000) {
13446
+ async loginWithOAuth(selector, settleTimeoutMs = 30_000, consentProvider, expectedGoogleAccountEmail) {
13766
13447
  const product = this.page;
13767
13448
  const context = this.context;
13768
13449
  if (product === null || product.isClosed() || context === null) {
13769
13450
  throw new Error("OAuth login cannot start because the product page is unavailable");
13770
13451
  }
13771
- this.maybeAttachOAuthNetListener();
13772
13452
  this.oauthProductPage = product;
13773
13453
  this.oauthProviderPage = null;
13774
13454
  this.oauthProviderPageClosed = false;
13455
+ const oauthBudgetMs = Math.max(1, settleTimeoutMs);
13775
13456
  const productUrl = product.url();
13457
+ const oauthDeadline = Date.now() + oauthBudgetMs;
13458
+ const remainingBudgetMs = () => Math.max(1, oauthDeadline - Date.now());
13459
+ const deadlineError = () => consentProvider === "google"
13460
+ ? Object.assign(new Error(`google_session: OAuth did not complete within ${Math.ceil(oauthBudgetMs / 1000)} seconds; ` +
13461
+ "the saved session may have expired, so reconnect with " +
13462
+ "`npx @trusty-squire/mcp connect --force-relogin=google` before retrying"), { code: "google_session" })
13463
+ : new Error(`OAuth login is still awaiting the provider after ${Math.ceil(oauthBudgetMs / 1000)} seconds. Retry oauth_login; do not read or close the browser session.`);
13776
13464
  let recovery = null;
13777
13465
  let providerPage = null;
13778
13466
  let productDeparted = false;
13467
+ let resolveProductDeparture = () => undefined;
13468
+ const productDeparturePromise = new Promise((resolve) => {
13469
+ resolveProductDeparture = resolve;
13470
+ });
13779
13471
  const onProductNavigation = (frame) => {
13780
13472
  if (frame !== product.mainFrame())
13781
13473
  return;
13782
- if (!this.isOAuthProductUrl(frame.url(), productUrl))
13474
+ if (!this.isOAuthProductUrl(frame.url(), productUrl)) {
13783
13475
  productDeparted = true;
13476
+ resolveProductDeparture();
13477
+ }
13784
13478
  };
13785
13479
  product.on("framenavigated", onProductNavigation);
13786
13480
  try {
13787
13481
  recovery = await context.newPage();
13788
- await recovery.goto(productUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
13482
+ await recovery.goto(productUrl, {
13483
+ waitUntil: "domcontentloaded",
13484
+ timeout: remainingBudgetMs(),
13485
+ });
13789
13486
  let resolvePopup = () => undefined;
13790
13487
  const popupPromise = new Promise((resolve) => {
13791
13488
  resolvePopup = resolve;
@@ -13796,6 +13493,8 @@ export class BrowserController {
13796
13493
  };
13797
13494
  context.on("page", onPopup);
13798
13495
  try {
13496
+ if (Date.now() >= oauthDeadline)
13497
+ throw deadlineError();
13799
13498
  try {
13800
13499
  await this.click(selector);
13801
13500
  }
@@ -13805,25 +13504,56 @@ export class BrowserController {
13805
13504
  }
13806
13505
  providerPage = await Promise.race([
13807
13506
  popupPromise,
13808
- this.sleep(Math.min(settleTimeoutMs, 2_000)).then(() => null),
13507
+ // A same-tab provider redirect is just as conclusive as a popup.
13508
+ // Do not burn two seconds of the OAuth budget waiting for a window
13509
+ // that this service will never open.
13510
+ productDeparturePromise.then(() => null),
13511
+ this.sleep(Math.min(remainingBudgetMs(), 2_000)).then(() => null),
13809
13512
  ]);
13810
13513
  }
13811
13514
  finally {
13812
13515
  context.off("page", onPopup);
13813
13516
  resolvePopup(null);
13517
+ resolveProductDeparture();
13814
13518
  }
13815
13519
  const transient = providerPage ?? product;
13520
+ productDeparted = productDeparted || !this.isOAuthProductUrl(transient.url(), productUrl);
13816
13521
  const durableProduct = providerPage === null ? recovery : product;
13817
13522
  this.oauthProductPage = durableProduct;
13818
13523
  this.oauthProviderPage = transient;
13819
13524
  this.oauthProviderPageClosed = transient.isClosed();
13820
13525
  this.restoreProductPageWhenOAuthPageCloses(transient, durableProduct);
13821
- const settled = await this.waitForOAuthLifecycle(transient, productUrl, settleTimeoutMs, providerPage === null, productDeparted);
13526
+ this.page = transient;
13527
+ let settled = null;
13528
+ if (consentProvider === undefined) {
13529
+ settled = await this.waitForOAuthLifecycle(transient, productUrl, remainingBudgetMs(), providerPage === null, productDeparted);
13530
+ }
13531
+ else {
13532
+ const deadline = oauthDeadline;
13533
+ while (settled === null && Date.now() < deadline) {
13534
+ const remaining = deadline - Date.now();
13535
+ settled = await this.waitForOAuthLifecycle(transient, productUrl, Math.min(1_000, remaining), providerPage === null, productDeparted);
13536
+ if (settled !== null || transient.isClosed())
13537
+ break;
13538
+ productDeparted = productDeparted || !this.isOAuthProductUrl(transient.url(), productUrl);
13539
+ if (Date.now() >= deadline)
13540
+ break;
13541
+ const consentBudgetMs = deadline - Date.now();
13542
+ const advanced = await this.advanceOAuthConsent(consentProvider, consentBudgetMs, expectedGoogleAccountEmail).catch(() => false);
13543
+ if (!advanced)
13544
+ await this.sleep(Math.min(250, remainingBudgetMs()));
13545
+ }
13546
+ }
13822
13547
  if (settled === null) {
13823
- throw new Error(`OAuth login is still awaiting the provider after ${Math.ceil(settleTimeoutMs / 1000)} seconds. Retry oauth_login; do not read or close the browser session.`);
13548
+ throw deadlineError();
13824
13549
  }
13825
13550
  if (providerPage === null && product.isClosed()) {
13826
- await recovery.reload({ waitUntil: "domcontentloaded", timeout: 30_000 });
13551
+ if (Date.now() >= oauthDeadline)
13552
+ throw deadlineError();
13553
+ await recovery.reload({
13554
+ waitUntil: "domcontentloaded",
13555
+ timeout: remainingBudgetMs(),
13556
+ });
13827
13557
  }
13828
13558
  }
13829
13559
  finally {
@@ -13842,7 +13572,9 @@ export class BrowserController {
13842
13572
  if (this.page !== null && !this.page.isClosed()) {
13843
13573
  await this.page.bringToFront().catch(() => undefined);
13844
13574
  await this.page
13845
- .waitForLoadState("domcontentloaded", { timeout: 30_000 })
13575
+ .waitForLoadState("domcontentloaded", {
13576
+ timeout: remainingBudgetMs(),
13577
+ })
13846
13578
  .catch(() => undefined);
13847
13579
  }
13848
13580
  }
@@ -13882,12 +13614,23 @@ export class BrowserController {
13882
13614
  const url = page.url();
13883
13615
  const isProduct = this.isOAuthProductUrl(url, productUrl);
13884
13616
  if (startsOnProduct && departed && isProduct) {
13885
- const remaining = Math.max(1, deadline - Date.now());
13886
- const idle = await page
13887
- .waitForLoadState("networkidle", { timeout: remaining })
13617
+ // A return to the relying party is the OAuth completion signal. A
13618
+ // dashboard can keep polling or streaming forever, so networkidle is
13619
+ // not a valid requirement for a completed OAuth redirect.
13620
+ const returnedUrl = url;
13621
+ const ready = await page
13622
+ .waitForLoadState("domcontentloaded", { timeout: Math.max(1, deadline - Date.now()) })
13888
13623
  .then(() => true)
13889
13624
  .catch(() => false);
13890
- return idle && !page.isClosed() && this.isOAuthProductUrl(page.url(), productUrl)
13625
+ if (!ready || page.isClosed() || !this.isOAuthProductUrl(page.url(), productUrl)) {
13626
+ return null;
13627
+ }
13628
+ // Require the return URL to survive one event-loop turn so a transient
13629
+ // callback hop is never reported as the final product page.
13630
+ await this.sleep(Math.min(50, Math.max(1, deadline - Date.now())));
13631
+ return !page.isClosed() &&
13632
+ page.url() === returnedUrl &&
13633
+ this.isOAuthProductUrl(page.url(), productUrl)
13891
13634
  ? "returned"
13892
13635
  : null;
13893
13636
  }
@@ -13897,118 +13640,6 @@ export class BrowserController {
13897
13640
  }
13898
13641
  return page.isClosed() ? "closed" : null;
13899
13642
  }
13900
- // ── OAuth/SSO debug instrumentation (UNIVERSAL_BOT_OAUTH_DEBUG) ──
13901
- // Attach a context-level response recorder ONCE. Records SSO-relevant
13902
- // responses (the service host + Clerk/Stytch/WorkOS FAPI hosts) with their
13903
- // status + whether they set a cookie — the signal for "did the callback's
13904
- // session-establish request succeed and set the session cookie?"
13905
- maybeAttachOAuthNetListener() {
13906
- if (this.oauthNetListenerAttached)
13907
- return;
13908
- if (!/^(1|true|on)$/i.test(process.env.UNIVERSAL_BOT_OAUTH_DEBUG ?? ""))
13909
- return;
13910
- if (!this.context)
13911
- return;
13912
- this.oauthNetListenerAttached = true;
13913
- const RELEVANT = /clerk|stytch|workos|accounts\.|\/sso|\/oauth|\/session|\/sign|callback|\/v1\/client/i;
13914
- this.context.on("response", (res) => {
13915
- void (async () => {
13916
- try {
13917
- const url = res.url();
13918
- if (!RELEVANT.test(url))
13919
- return;
13920
- const headers = res.headers();
13921
- if (this.oauthNetLog.length >= 200)
13922
- return;
13923
- const entry = {
13924
- url: url.slice(0, 200),
13925
- status: res.status(),
13926
- setCookie: "set-cookie" in headers || "Set-Cookie" in headers,
13927
- ct: (headers["content-type"] ?? "").slice(0, 40),
13928
- };
13929
- // Capture the body of a Clerk/Stytch/WorkOS sign-in/up/callback error
13930
- // (>=400) — its error code is the definitive tell (captcha_invalid vs
13931
- // transfer vs identifier_*). JSON only, bounded.
13932
- if (res.status() >= 400 &&
13933
- /\/v1\/client\/(sign_ins|sign_ups)|oauth_callback|\/session/i.test(url)) {
13934
- entry.body = (await res.text().catch(() => "")).slice(0, 800);
13935
- }
13936
- this.oauthNetLog.push(entry);
13937
- }
13938
- catch {
13939
- // best-effort observability — never perturb the run
13940
- }
13941
- })();
13942
- });
13943
- }
13944
- // Dump cookies + the SSO network log to a file for post-mortem. Called at the
13945
- // oauth_session_not_persisted decision point when UNIVERSAL_BOT_OAUTH_DEBUG.
13946
- async dumpOAuthDebug(service, label) {
13947
- if (!/^(1|true|on)$/i.test(process.env.UNIVERSAL_BOT_OAUTH_DEBUG ?? ""))
13948
- return;
13949
- if (!this.context)
13950
- return;
13951
- try {
13952
- const cookies = await this.context.cookies();
13953
- const cookieSummary = cookies.map((c) => ({
13954
- name: c.name,
13955
- domain: c.domain,
13956
- path: c.path,
13957
- len: c.value.length,
13958
- httpOnly: c.httpOnly,
13959
- secure: c.secure,
13960
- sameSite: c.sameSite,
13961
- }));
13962
- const url = this.page ? this.page.url() : "(no page)";
13963
- // Capture the live Clerk SDK state — the definitive read on whether a
13964
- // sign-up transfer is available (the new-user-OAuth fix hinges on this).
13965
- const clerkState = this.page
13966
- ? await this.page
13967
- .evaluate(() => {
13968
- const w = window;
13969
- const c = w.Clerk;
13970
- if (c === undefined)
13971
- return { present: false };
13972
- const client = c.client;
13973
- const si = client?.["signIn"];
13974
- const su = client?.["signUp"];
13975
- const ffv = si?.["firstFactorVerification"];
13976
- return {
13977
- present: true,
13978
- loaded: c.loaded ?? null,
13979
- signInStatus: si?.["status"] ?? null,
13980
- signInFFVStatus: ffv?.["status"] ?? null,
13981
- signInFFVError: ffv?.["error"] ?? null,
13982
- signUpStatus: su?.["status"] ?? null,
13983
- signUpMissingFields: su?.["missingFields"] ?? null,
13984
- hasSignUpCreate: typeof su?.create === "function",
13985
- };
13986
- })
13987
- .catch((e) => ({ present: "evalError", err: String(e).slice(0, 120) }))
13988
- : { present: false };
13989
- const consoleText = await this.extractText().catch(() => "");
13990
- const { writeFileSync, mkdirSync } = await import("node:fs");
13991
- const { join } = await import("node:path");
13992
- const { homedir } = await import("node:os");
13993
- const dir = join(homedir(), ".trusty-squire", "oauth-debug");
13994
- mkdirSync(dir, { recursive: true });
13995
- const ts = process.env.OAUTH_DEBUG_TS ?? String(this.oauthNetLog.length);
13996
- const path = join(dir, `${service}-${label}-${ts}.json`);
13997
- writeFileSync(path, JSON.stringify({
13998
- service,
13999
- label,
14000
- finalUrl: url,
14001
- clerkState,
14002
- cookies: cookieSummary,
14003
- netLog: this.oauthNetLog,
14004
- pageText: consoleText.slice(0, 600),
14005
- }, null, 2));
14006
- console.error(`[oauth-debug] wrote ${path} (${cookieSummary.length} cookies, ${this.oauthNetLog.length} net entries)`);
14007
- }
14008
- catch (err) {
14009
- console.error(`[oauth-debug] dump failed: ${err instanceof Error ? err.message : String(err)}`);
14010
- }
14011
- }
14012
13643
  // Read the page's Rails/OmniAuth CSRF token (<meta name="csrf-token">).
14013
13644
  // Needed to recover OmniAuth 2.0 POST-only OAuth when the provider button is
14014
13645
  // a GET <a> that page-JS upgrades to a POST.
@@ -14257,6 +13888,67 @@ export class BrowserController {
14257
13888
  recoverActivePage() {
14258
13889
  return this.adoptLivePage();
14259
13890
  }
13891
+ // ───────────── new-tab adoption ─────────────
13892
+ //
13893
+ // Arm adoption immediately BEFORE an action that may open a tab. Anything
13894
+ // already queued belonged to an earlier action and is not this action's to
13895
+ // follow.
13896
+ armOpenedTabAdoption() {
13897
+ this.openedTabs.length = 0;
13898
+ }
13899
+ // Adopt the newest live tab opened since armOpenedTabAdoption() as the active
13900
+ // page, so the next observe/act reads the tab the click actually opened.
13901
+ // Returns the adopted URL, or null when the action opened no followable tab.
13902
+ //
13903
+ // `graceMs` covers the window between the click returning and Playwright
13904
+ // delivering the context "page" event; a caller that has already waited for
13905
+ // the page to settle passes 0 and just drains what arrived.
13906
+ async adoptOpenedTab(graceMs = 0) {
13907
+ const deadline = Date.now() + Math.max(0, graceMs);
13908
+ let candidate = this.takeFollowableTab();
13909
+ while (candidate === null && Date.now() < deadline) {
13910
+ await this.sleep(50);
13911
+ candidate = this.takeFollowableTab();
13912
+ }
13913
+ if (candidate === null)
13914
+ return null;
13915
+ this.openedTabs.length = 0;
13916
+ // A window.open target starts at about:blank and is navigated a tick later.
13917
+ // Adopting it while blank would report an empty page to the host, so wait
13918
+ // (bounded) for the document it was opened for.
13919
+ const blank = (url) => url === "" || url === "about:blank" || url === "about:srcdoc";
13920
+ for (let i = 0; i < 40 && !candidate.isClosed() && blank(candidate.url()); i++) {
13921
+ await this.sleep(50);
13922
+ }
13923
+ if (candidate.isClosed())
13924
+ return null;
13925
+ this.page = candidate;
13926
+ this.trackMainDocument(candidate);
13927
+ await candidate.bringToFront().catch(() => undefined);
13928
+ await candidate
13929
+ .waitForLoadState("domcontentloaded", { timeout: 15_000 })
13930
+ .catch(() => undefined);
13931
+ return candidate.isClosed() ? null : candidate.url();
13932
+ }
13933
+ // Newest queued tab the operator may follow. Pages the controller itself owns
13934
+ // (the active page, the primary page, either side of an OAuth handshake, a
13935
+ // recovery tab) are never adoption candidates — those lifecycles are managed
13936
+ // by the code that created them.
13937
+ takeFollowableTab() {
13938
+ for (let i = this.openedTabs.length - 1; i >= 0; i--) {
13939
+ const tab = this.openedTabs[i];
13940
+ if (tab.isClosed())
13941
+ continue;
13942
+ if (tab === this.page ||
13943
+ tab === this.primaryPage ||
13944
+ tab === this.oauthProductPage ||
13945
+ tab === this.oauthProviderPage) {
13946
+ continue;
13947
+ }
13948
+ return tab;
13949
+ }
13950
+ return null;
13951
+ }
14260
13952
  adoptLivePage() {
14261
13953
  if (this.page !== null && !this.page.isClosed())
14262
13954
  return true;
@@ -14494,14 +14186,7 @@ export class BrowserController {
14494
14186
  return false;
14495
14187
  }
14496
14188
  }
14497
- // Which OAuth providers have a LIVE session in this profile's cookie jar.
14498
- // The logged-in-providers.json marker is a memo that drifts out of sync
14499
- // (a --force-relogin clears it, a misclassified run clears it, a parallel
14500
- // run overwrites it) — so a session that is genuinely live in the cookies
14501
- // can go invisible to provider selection, which is exactly how a warm
14502
- // GitHub session got skipped in favour of a broken Google path. The cookie
14503
- // jar is the ground truth: read it directly. Cookie NAMES + presence only;
14504
- // values are never read into logs. Best-effort — a read failure returns [].
14189
+ // Which OAuth providers have a live session in this profile's cookie jar.
14505
14190
  async detectSessionProviders() {
14506
14191
  if (this.context === null)
14507
14192
  return [];
@@ -14512,13 +14197,18 @@ export class BrowserController {
14512
14197
  return [];
14513
14198
  }
14514
14199
  }
14515
- async detectGoogleAccountEmail() {
14200
+ async detectGoogleAccountEmail(expectedGoogleAccountEmail) {
14516
14201
  if (this.context === null)
14517
14202
  return null;
14518
14203
  let identityPage = null;
14519
14204
  try {
14520
14205
  identityPage = await this.context.newPage();
14521
- await identityPage.goto("https://myaccount.google.com/", {
14206
+ const identityUrl = new URL("https://myaccount.google.com/");
14207
+ const expectedEmail = expectedGoogleAccountEmail?.trim();
14208
+ if (expectedEmail !== undefined && expectedEmail.length > 0) {
14209
+ identityUrl.searchParams.set("authuser", expectedEmail);
14210
+ }
14211
+ await identityPage.goto(identityUrl.href, {
14522
14212
  waitUntil: "domcontentloaded",
14523
14213
  timeout: 20_000,
14524
14214
  });
@@ -14542,13 +14232,18 @@ export class BrowserController {
14542
14232
  await identityPage?.close().catch(() => undefined);
14543
14233
  }
14544
14234
  }
14545
- // Advance a provider's consent / account-chooser screen by one click
14546
- // — the scope-gated auto-approve (T7/T13). Returns false when no
14235
+ // Advance a provider's consent / account-chooser screen by one click.
14236
+ // Returns false when no
14547
14237
  // approve control is present — the agent then aborts rather than
14548
14238
  // hang. Clicks only; never types (the critical guarantee holds here).
14549
- async advanceOAuthConsent(provider) {
14239
+ async advanceOAuthConsent(provider, timeoutMs = 8_000, expectedGoogleAccountEmail) {
14550
14240
  if (!this.page)
14551
14241
  throw new Error("Browser not started");
14242
+ const deadline = Date.now() + Math.max(0, timeoutMs);
14243
+ const hasBudget = () => Date.now() < deadline;
14244
+ const boundedTimeout = (limitMs) => Math.max(1, Math.min(limitMs, deadline - Date.now()));
14245
+ if (!hasBudget())
14246
+ return false;
14552
14247
  if (provider === "github") {
14553
14248
  // GitHub App install flow can include an account target chooser before
14554
14249
  // the Install/Authorize screen:
@@ -14558,8 +14253,12 @@ export class BrowserController {
14558
14253
  // consent loop re-classify the next GitHub page.
14559
14254
  if (/\/apps\/[^/]+\/installations\/select_target\b/.test(new URL(this.page.url()).pathname)) {
14560
14255
  const startUrl = this.page.url();
14256
+ if (!hasBudget())
14257
+ return false;
14561
14258
  const clicked = await this.page
14562
- .evaluate(() => {
14259
+ .evaluate((expiresAt) => {
14260
+ if (Date.now() >= expiresAt)
14261
+ return false;
14563
14262
  const visible = (el) => {
14564
14263
  const r = el.getBoundingClientRect();
14565
14264
  const s = window.getComputedStyle(el);
@@ -14584,13 +14283,17 @@ export class BrowserController {
14584
14283
  });
14585
14284
  if (target === undefined)
14586
14285
  return false;
14286
+ if (Date.now() >= expiresAt)
14287
+ return false;
14587
14288
  target.click();
14588
14289
  return true;
14589
- })
14290
+ }, deadline)
14590
14291
  .catch(() => false);
14591
14292
  if (clicked) {
14592
14293
  const advanced = await this.page
14593
- .waitForFunction((s) => window.location.href !== s, startUrl, { timeout: 8000 })
14294
+ .waitForFunction((s) => window.location.href !== s, startUrl, {
14295
+ timeout: boundedTimeout(8_000),
14296
+ })
14594
14297
  .then(() => true)
14595
14298
  .catch(() => false);
14596
14299
  if (advanced)
@@ -14625,7 +14328,7 @@ export class BrowserController {
14625
14328
  // (MEASURED 2026-06-11: defang's "Authorize DefangLabs"). Poll up to
14626
14329
  // 12s for it to enable before clicking.
14627
14330
  {
14628
- const deadline = Date.now() + 12_000;
14331
+ const deadline = Date.now() + boundedTimeout(12_000);
14629
14332
  while (Date.now() < deadline) {
14630
14333
  const disabled = await btn
14631
14334
  .evaluate((el) => {
@@ -14642,8 +14345,10 @@ export class BrowserController {
14642
14345
  await this.sleep(400);
14643
14346
  }
14644
14347
  }
14348
+ if (!hasBudget())
14349
+ return false;
14645
14350
  try {
14646
- await btn.click({ timeout: 8000 });
14351
+ await btn.click({ timeout: boundedTimeout(8_000) });
14647
14352
  }
14648
14353
  catch {
14649
14354
  continue;
@@ -14653,7 +14358,9 @@ export class BrowserController {
14653
14358
  // click silently failed (wrong element, or button disabled
14654
14359
  // behind a hidden iframe). Return false so the caller knows.
14655
14360
  const advanced = await this.page
14656
- .waitForFunction((s) => window.location.href !== s, startUrl, { timeout: 4000 })
14361
+ .waitForFunction((s) => window.location.href !== s, startUrl, {
14362
+ timeout: boundedTimeout(4_000),
14363
+ })
14657
14364
  .then(() => true)
14658
14365
  .catch(() => false);
14659
14366
  if (advanced)
@@ -14686,10 +14393,89 @@ export class BrowserController {
14686
14393
  }
14687
14394
  // Google. Account chooser: Google renders each account with a
14688
14395
  // stable data-identifier attribute (the account email).
14689
- const tile = this.page.locator("[data-identifier]").first();
14690
- if ((await tile.count().catch(() => 0)) > 0) {
14396
+ const tiles = this.page.locator("[data-identifier]");
14397
+ const expectedEmail = expectedGoogleAccountEmail?.trim().toLowerCase() ?? null;
14398
+ const matchingTileIndexes = await tiles
14399
+ .evaluateAll((elements, expected) => {
14400
+ return elements.flatMap((element, index) => {
14401
+ const html = element;
14402
+ const rect = html.getBoundingClientRect();
14403
+ const style = window.getComputedStyle(html);
14404
+ const identifier = element.getAttribute("data-identifier")?.trim().toLowerCase();
14405
+ return rect.width >= 2 &&
14406
+ rect.height >= 2 &&
14407
+ style.display !== "none" &&
14408
+ style.visibility !== "hidden" &&
14409
+ identifier !== undefined &&
14410
+ (expected === null || identifier === expected)
14411
+ ? [index]
14412
+ : [];
14413
+ });
14414
+ }, expectedEmail)
14415
+ .catch(() => []);
14416
+ // Without account metadata, a sole visible chooser tile is still
14417
+ // unambiguous. Multiple accounts remain intentionally untouched.
14418
+ if (matchingTileIndexes.length === 1) {
14419
+ if (!hasBudget())
14420
+ return false;
14421
+ try {
14422
+ await tiles.nth(matchingTileIndexes[0]).click({ timeout: boundedTimeout(1_000) });
14423
+ return true;
14424
+ }
14425
+ catch {
14426
+ // fall through to the approve-button path
14427
+ }
14428
+ }
14429
+ // Google's current account chooser also renders an identity as
14430
+ // an ordinary semantic button/link without data-identifier. Select only a
14431
+ // visible account-shaped row carrying an email address, and exclude only
14432
+ // account-management alternatives. Authentication state is not inferred
14433
+ // from provider-page content; completion remains the OAuth lifecycle signal.
14434
+ // The identity text stays inside the provider page and is never returned.
14435
+ const accountRows = this.page.locator('button, [role="button"], [role="link"], a[href]');
14436
+ const accountRowLabels = await accountRows
14437
+ .evaluateAll((elements) => {
14438
+ const EXCLUDED = /(?:\buse another account|\bremove an account|\bmanage accounts?\b)/i;
14439
+ return elements.map((element, index) => {
14440
+ const html = element;
14441
+ const rect = html.getBoundingClientRect();
14442
+ const style = window.getComputedStyle(html);
14443
+ if (rect.width < 2 ||
14444
+ rect.height < 2 ||
14445
+ style.display === "none" ||
14446
+ style.visibility === "hidden") {
14447
+ return null;
14448
+ }
14449
+ const labels = [
14450
+ element.getAttribute("aria-label") ?? "",
14451
+ element.textContent ?? "",
14452
+ ...Array.from(element.querySelectorAll("[aria-label], *")).flatMap((descendant) => [
14453
+ descendant.getAttribute("aria-label") ?? "",
14454
+ descendant.textContent ?? "",
14455
+ ]),
14456
+ ]
14457
+ .map((label) => label.replace(/\s+/g, " ").trim())
14458
+ .filter((label) => label.length > 0);
14459
+ return labels.some((label) => EXCLUDED.test(label)) ? null : { index, labels };
14460
+ });
14461
+ })
14462
+ .catch(() => []);
14463
+ const matchingAccountRows = expectedEmail === null
14464
+ ? []
14465
+ : accountRowLabels.filter((candidate) => {
14466
+ if (candidate === null)
14467
+ return false;
14468
+ return candidate.labels.some((label) => {
14469
+ const emails = label.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) ?? [];
14470
+ return emails.some((email) => email.toLowerCase() === expectedEmail);
14471
+ });
14472
+ });
14473
+ const accountRowIndex = matchingAccountRows.length === 1 ? matchingAccountRows[0].index : -1;
14474
+ if (accountRowIndex >= 0) {
14475
+ if (!hasBudget())
14476
+ return false;
14691
14477
  try {
14692
- await tile.click({ timeout: 8000 });
14478
+ await accountRows.nth(accountRowIndex).click({ timeout: boundedTimeout(1_000) });
14693
14479
  return true;
14694
14480
  }
14695
14481
  catch {
@@ -14707,14 +14493,16 @@ export class BrowserController {
14707
14493
  const APPROVE_NAME = /^(?:continue|allow|accept|agree)\b/i;
14708
14494
  const approve = this.page.getByRole("button", { name: APPROVE_NAME }).first();
14709
14495
  try {
14710
- await approve.waitFor({ state: "visible", timeout: 8000 });
14496
+ await approve.waitFor({ state: "visible", timeout: boundedTimeout(1_000) });
14711
14497
  }
14712
14498
  catch {
14713
14499
  // not visible within the window — fall through to the DOM-scan path
14714
14500
  }
14715
14501
  if ((await approve.count().catch(() => 0)) > 0) {
14502
+ if (!hasBudget())
14503
+ return false;
14716
14504
  try {
14717
- await approve.click({ timeout: 8000 });
14505
+ await approve.click({ timeout: boundedTimeout(1_000) });
14718
14506
  return true;
14719
14507
  }
14720
14508
  catch {
@@ -14726,8 +14514,12 @@ export class BrowserController {
14726
14514
  // <div role>/<span> or an <input type=submit value="Allow access">).
14727
14515
  // Click the first visible candidate whose text is an approve verb and
14728
14516
  // is NOT a cancel/deny/back. Log what was visible on failure.
14517
+ if (!hasBudget())
14518
+ return false;
14729
14519
  const clicked = await this.page
14730
- .evaluate(() => {
14520
+ .evaluate((expiresAt) => {
14521
+ if (Date.now() >= expiresAt)
14522
+ return null;
14731
14523
  const APPROVE = /^(?:continue|allow|accept|agree)\b/i;
14732
14524
  const DENY = /\b(?:cancel|deny|back|no\b|not now|reject)\b/i;
14733
14525
  const els = Array.from(document.querySelectorAll('button, input[type="submit"], [role="button"], a[href]'));
@@ -14741,12 +14533,14 @@ export class BrowserController {
14741
14533
  if (DENY.test(t))
14742
14534
  continue;
14743
14535
  if (APPROVE.test(t)) {
14536
+ if (Date.now() >= expiresAt)
14537
+ return null;
14744
14538
  el.click();
14745
14539
  return t.slice(0, 40);
14746
14540
  }
14747
14541
  }
14748
14542
  return null;
14749
- })
14543
+ }, deadline)
14750
14544
  .catch(() => null);
14751
14545
  if (clicked !== null)
14752
14546
  return true;
@@ -14826,51 +14620,6 @@ export class BrowserController {
14826
14620
  this.oauthProviderPageClosed = false;
14827
14621
  }
14828
14622
  }
14829
- // End one sequential task without tearing down Chrome. The original,
14830
- // handler-bound page survives; OAuth popups and any other incidental tabs are
14831
- // closed. about:blank replaces the task document while the persistent profile
14832
- // (cookies, OAuth identity, local profile data) intentionally remains intact.
14833
- async resetPageForReuse() {
14834
- if (!this.profilePoolReusable)
14835
- throw new Error("browser profile is not pool-reusable");
14836
- const context = this.context;
14837
- const primaryPage = this.primaryPage;
14838
- if (!this.isConnected() || context === null || primaryPage === null || primaryPage.isClosed()) {
14839
- throw new Error("warm browser page is not reusable");
14840
- }
14841
- this.page = primaryPage;
14842
- this.oauthProductPage = null;
14843
- this.oauthProviderPage = null;
14844
- this.oauthProviderPageClosed = false;
14845
- this.oauthNetLog = [];
14846
- this.mouseX = 100;
14847
- this.mouseY = 100;
14848
- for (const page of context.pages()) {
14849
- if (page !== primaryPage && !page.isClosed()) {
14850
- let timer;
14851
- try {
14852
- await Promise.race([
14853
- page.close(),
14854
- new Promise((_, reject) => {
14855
- timer = setTimeout(() => reject(new Error("timed out closing warm browser popup")), 5_000);
14856
- timer.unref();
14857
- }),
14858
- ]);
14859
- }
14860
- finally {
14861
- if (timer !== undefined)
14862
- clearTimeout(timer);
14863
- }
14864
- }
14865
- }
14866
- if (context.pages().some((page) => page !== primaryPage && !page.isClosed())) {
14867
- throw new Error("warm browser reset left a non-primary page open");
14868
- }
14869
- await primaryPage.goto("about:blank", {
14870
- waitUntil: "domcontentloaded",
14871
- timeout: 30_000,
14872
- });
14873
- }
14874
14623
  async close(options = {}) {
14875
14624
  if (options.cancelStart === true) {
14876
14625
  this.startCancellationRequested = true;
@@ -14880,10 +14629,22 @@ export class BrowserController {
14880
14629
  this.closePromise ??= this.closeAfterStart();
14881
14630
  return await this.closePromise;
14882
14631
  }
14632
+ async waitForCancelledStartQuiescence() {
14633
+ if (!this.startCancellationRequested)
14634
+ return;
14635
+ await Promise.allSettled([
14636
+ this.startPromise ?? Promise.resolve(),
14637
+ this.reapCancelledStartProcess(),
14638
+ ]);
14639
+ await this.persistentFallbackOwnershipMonitor?.catch(() => undefined);
14640
+ }
14883
14641
  async forceCloseOwnedProcessTree() {
14884
14642
  this.startCancellationRequested = true;
14885
14643
  this.resolveStartCancellation?.();
14886
14644
  this.resolveStartCancellation = null;
14645
+ const marker = this.operatorBrowserMarker();
14646
+ if (this.ownerLaunchTracked)
14647
+ markOwnerBrowserLaunchTerminal(marker);
14887
14648
  const proof = this.ownedChromeProcessTreeProof;
14888
14649
  const identity = proof?.identity ?? this.currentOwnedProfileIdentity();
14889
14650
  if (identity !== null) {
@@ -14903,43 +14664,24 @@ export class BrowserController {
14903
14664
  if (this.ownedChromeProcessTreeProof === proof)
14904
14665
  this.ownedChromeProcessTreeProof = null;
14905
14666
  }
14906
- const lease = this.profileOperationLease;
14907
- this.profileOperationLease = null;
14908
- lease?.release();
14909
- if (this.xvfb !== null) {
14910
- try {
14911
- this.xvfb.stop();
14912
- }
14913
- catch { }
14914
- this.xvfb = null;
14667
+ const markerClosed = !this.ownerLaunchTracked || (await terminateOwnerBrowserLaunch(marker, this.profileDir));
14668
+ if (closed && markerClosed && this.ownerLaunchTracked) {
14669
+ untrackOwnerBrowserLaunch(marker);
14670
+ this.ownerLaunchTracked = false;
14915
14671
  }
14916
- return closed ? "closed" : "unknown";
14672
+ await this.teardownOwnedDisplay().catch(() => undefined);
14673
+ return closed && markerClosed ? "closed" : "unknown";
14917
14674
  }
14918
14675
  async closeCancelledStart() {
14919
14676
  void this.reapCancelledStartProcess().catch(() => undefined);
14920
14677
  if (this.persistentFallbackCancellationState !== null) {
14921
- const closeState = this.persistentFallbackCancellationState;
14922
- if (closeState === "closed") {
14923
- const lease = this.profileOperationLease;
14924
- this.profileOperationLease = null;
14925
- lease?.release();
14926
- }
14927
- return closeState;
14678
+ return this.persistentFallbackCancellationState;
14928
14679
  }
14929
14680
  if (!this.startLaunchCommitted) {
14930
- const closeState = await this.closeWithProfileGuard();
14931
- const lease = this.profileOperationLease;
14932
- this.profileOperationLease = null;
14933
- lease?.release();
14681
+ const closeState = await this.closeBrowser();
14934
14682
  return this.startSettled ? closeState : "unknown";
14935
14683
  }
14936
- const closeState = await this.closeWithProfileGuard();
14937
- if (this.startSettled) {
14938
- const lease = this.profileOperationLease;
14939
- this.profileOperationLease = null;
14940
- lease?.release();
14941
- }
14942
- return closeState;
14684
+ return await this.closeBrowser();
14943
14685
  }
14944
14686
  async reapCancelledStartProcess() {
14945
14687
  this.cancelledStartReaper ??= this.monitorCancelledStartProcess();
@@ -14970,7 +14712,7 @@ export class BrowserController {
14970
14712
  const identity = profileProcessIdentity(holderPid, this.profileDir);
14971
14713
  if (identity === null)
14972
14714
  return null;
14973
- if (!this.startCancellationRequested || this.profileOperationLease !== null)
14715
+ if (!this.startCancellationRequested)
14974
14716
  return identity;
14975
14717
  return operatorBrowserProcessMatchesMarker(identity.pid, this.operatorBrowserMarker())
14976
14718
  ? identity
@@ -14983,7 +14725,6 @@ export class BrowserController {
14983
14725
  const proof = await resolvePersistentFallbackIdentity({ profileDir: this.profileDir });
14984
14726
  if (proof.state === "owned" &&
14985
14727
  this.startCancellationRequested &&
14986
- this.profileOperationLease === null &&
14987
14728
  !operatorBrowserProcessMatchesMarker(proof.identity.pid, this.operatorBrowserMarker())) {
14988
14729
  return { state: "unknown" };
14989
14730
  }
@@ -14991,6 +14732,20 @@ export class BrowserController {
14991
14732
  this.adoptOwnedChromeProcessTree(proof.identity, false);
14992
14733
  return proof;
14993
14734
  }
14735
+ async requirePersistentFallbackOwnership(cleanupUnproven) {
14736
+ try {
14737
+ const proof = await this.waitForPersistentFallbackIdentity();
14738
+ if (proof.state !== "owned" || this.ownedChromeProcessTreeProof === null) {
14739
+ throw new Error("persistent browser launch identity could not be bound to owner custody");
14740
+ }
14741
+ return proof.identity;
14742
+ }
14743
+ catch (error) {
14744
+ await cleanupUnproven().catch(() => undefined);
14745
+ this.persistentFallbackLaunchInFlight = false;
14746
+ throw error;
14747
+ }
14748
+ }
14994
14749
  startPersistentFallbackOwnershipMonitor() {
14995
14750
  if (this.persistentFallbackOwnershipMonitor !== null)
14996
14751
  return;
@@ -15000,11 +14755,13 @@ export class BrowserController {
15000
14755
  const identity = holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir);
15001
14756
  const controllerOwnsIdentity = identity !== null &&
15002
14757
  (!this.startCancellationRequested ||
15003
- this.profileOperationLease !== null ||
15004
14758
  operatorBrowserProcessMatchesMarker(identity.pid, this.operatorBrowserMarker()));
15005
14759
  if (identity !== null && controllerOwnsIdentity) {
15006
14760
  this.launchedProfileHolderIdentity = identity;
15007
- this.adoptOwnedChromeProcessTree(identity, false);
14761
+ try {
14762
+ this.adoptOwnedChromeProcessTree(identity, false);
14763
+ }
14764
+ catch { }
15008
14765
  return;
15009
14766
  }
15010
14767
  await new Promise((resolveWait) => {
@@ -15047,31 +14804,25 @@ export class BrowserController {
15047
14804
  }
15048
14805
  if (this.startCancellationRequested)
15049
14806
  return await this.closeCancelledStart();
15050
- try {
15051
- return await this.closeWithProfileGuard();
15052
- }
15053
- finally {
15054
- const lease = this.profileOperationLease;
15055
- this.profileOperationLease = null;
15056
- lease?.release();
15057
- }
14807
+ return await this.closeBrowser();
15058
14808
  }
15059
- async closeWithProfileGuard() {
14809
+ async closeBrowser() {
15060
14810
  if (this.harnessAttachedPage) {
15061
14811
  this.page = null;
15062
14812
  this.primaryPage = null;
15063
14813
  this.oauthProductPage = null;
15064
14814
  this.oauthProviderPage = null;
15065
14815
  this.oauthProviderPageClosed = false;
15066
- this.oauthNetLog = [];
15067
14816
  this.context = null;
15068
14817
  return "closed";
15069
14818
  }
14819
+ const marker = this.operatorBrowserMarker();
14820
+ if (this.ownerLaunchTracked)
14821
+ markOwnerBrowserLaunchTerminal(marker);
15070
14822
  // Each step is best-effort and independent: a throw closing the page
15071
- // or context must NOT skip the Xvfb teardown below, or the virtual
15072
- // display leaks (orphaned Xvfb procs pile up over a long-lived MCP
15073
- // server and, worse, the un-closed Chrome keeps the profile's
15074
- // SingletonLock held — bricking the next signup + `mcp login`).
14823
+ // or context must NOT skip the browser reap below, or an un-closed Chrome
14824
+ // keeps the profile's
14825
+ // SingletonLock held — bricking the next signup + `mcp connect`).
15075
14826
  //
15076
14827
  // EVERY close call is timeout-capped. On a wedged headed Chrome (e.g. a
15077
14828
  // run that crashed mid-captcha-click), BOTH page.close() AND
@@ -15096,7 +14847,6 @@ export class BrowserController {
15096
14847
  this.oauthProductPage = null;
15097
14848
  this.oauthProviderPage = null;
15098
14849
  this.oauthProviderPageClosed = false;
15099
- this.oauthNetLog = [];
15100
14850
  this.context = null;
15101
14851
  this.cdpBrowser = null;
15102
14852
  this.childChrome = null;
@@ -15113,7 +14863,7 @@ export class BrowserController {
15113
14863
  }
15114
14864
  // A process-tree SIGTERM can close the CDP target before Playwright
15115
14865
  // observes it. That is successful teardown, not a reason to skip the
15116
- // proof/reap path or quarantine a cleanly closed warm profile.
14866
+ // proof/reap path or retain a cleanly closed ephemeral profile.
15117
14867
  if (page !== null)
15118
14868
  await page.close().catch(() => undefined);
15119
14869
  if (context !== null)
@@ -15145,19 +14895,13 @@ export class BrowserController {
15145
14895
  this.ownedChromeProcessTreeProof = null;
15146
14896
  }
15147
14897
  }
15148
- // F13 — release the on-demand Xvfb if we spawned one. Order
15149
- // matters: kill Chrome (context.close) first so it has its
15150
- // display until it exits, THEN kill Xvfb.
15151
- if (this.xvfb !== null) {
15152
- try {
15153
- this.xvfb.stop();
15154
- }
15155
- catch {
15156
- /* best-effort */
15157
- }
15158
- this.xvfb = null;
14898
+ const markerClosed = !this.ownerLaunchTracked || (await terminateOwnerBrowserLaunch(marker, this.profileDir));
14899
+ if (markerClosed && this.ownerLaunchTracked) {
14900
+ untrackOwnerBrowserLaunch(marker);
14901
+ this.ownerLaunchTracked = false;
15159
14902
  }
15160
- return closeState;
14903
+ await this.teardownOwnedDisplay().catch(() => undefined);
14904
+ return closeState === "closed" && !markerClosed ? "force_closed_unproven" : closeState;
15161
14905
  }
15162
14906
  }
15163
14907
  // Random integer in [min, max]. We use Math.random() (not crypto)