@trusty-squire/mcp 1.1.13-rc.8 → 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 +139 -62
  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 +134 -77
  13. package/dist/bot/browser.d.ts.map +1 -1
  14. package/dist/bot/browser.js +1788 -1543
  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 +127 -0
  53. package/dist/bot/operator-browser-watchdog.d.ts.map +1 -0
  54. package/dist/bot/operator-browser-watchdog.js +438 -0
  55. package/dist/bot/operator-browser-watchdog.js.map +1 -0
  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 +13 -2
  65. package/dist/bot/pay-operator.d.ts.map +1 -1
  66. package/dist/bot/pay-operator.js +319 -123
  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 +60 -164
  75. package/dist/bot/provision-session.d.ts.map +1 -1
  76. package/dist/bot/provision-session.js +2049 -1546
  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 +163 -96
  142. package/dist/tools/operate-pay.js.map +1 -1
  143. package/dist/tools/provision-drive.d.ts +43 -35
  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,20 +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 { Socket, createServer } from "node:net";
28
- import { existsSync, mkdirSync, rmSync, statSync } from "node:fs";
29
- import { execFileSync, spawn } from "node:child_process";
26
+ import { Socket } from "node:net";
27
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
28
+ import { readFile } from "node:fs/promises";
29
+ import { join } from "node:path";
30
+ import { spawn } from "node:child_process";
30
31
  import { isSameRecipeDomain } from "@trusty-squire/recipe-schema";
31
- import { acquireFreeProfileOperationGuard, CHROME_PROFILE_DIR, clearStaleSingletonLock, closeProfileWithProof, currentProfileHolderPid, launchWithProfileGate, profilePathIdentity, profileProcessIdentity, profileProcessIdentityState, profileProcessMatches, PROFILE_BUSY_MESSAGE, ProfileBusyError, reapProfileHolderIfOwned, signalProfileProcess, waitForProfileFree, } from "./profile.js";
32
- import { GOOGLE_LOGIN_COOKIE_MARKERS, } from "./operator-profile-pool.js";
33
- import { startXvfb, xvfbAvailable } from "./xvfb.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";
34
35
  // Lazy registration: installing the plugin mutates the chromium singleton
35
36
  // from playwright-extra so we only do it once per process. We require()
36
37
  // the CJS modules lazily (the stealth toolchain only ships CJS) and treat
37
38
  // stealth as best-effort — a missing dep should never crash the bot.
38
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
+ }
39
102
  export function contextInitScriptsFor(options) {
40
103
  if (options.hardened)
41
104
  return [];
@@ -223,6 +286,34 @@ export class PaymentSubmitOutcomeUnknownError extends Error {
223
286
  this.name = "PaymentSubmitOutcomeUnknownError";
224
287
  }
225
288
  }
289
+ export async function runCaptureConfirmedPaymentSubmit(options) {
290
+ let clickError;
291
+ let inputDispatchPossible = false;
292
+ try {
293
+ await options.click(() => {
294
+ inputDispatchPossible = true;
295
+ });
296
+ }
297
+ catch (error) {
298
+ clickError = error;
299
+ }
300
+ if (clickError instanceof BrowserClickDispatchError &&
301
+ clickError.dispatchStatus === "not_dispatched") {
302
+ await options.clear();
303
+ throw clickError;
304
+ }
305
+ const evidence = await options.readEvidence();
306
+ await options.clear();
307
+ if (!evidence.dispatched) {
308
+ if (clickError !== undefined && !inputDispatchPossible)
309
+ throw clickError;
310
+ throw new PaymentSubmitOutcomeUnknownError();
311
+ }
312
+ options.onSubmitDispatched?.();
313
+ if (clickError !== undefined)
314
+ throw new PaymentSubmitOutcomeUnknownError();
315
+ return evidence.baseline;
316
+ }
226
317
  const CHECKOUT_TERMINAL_RESERVED_SEGMENTS = new Set([
227
318
  "about_blank",
228
319
  "blank",
@@ -414,6 +505,22 @@ function checkoutUrlOrderIdentities(rawUrl) {
414
505
  return null;
415
506
  }
416
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
+ }
417
524
  // EbisuMart — a widely-deployed Japanese EC platform (the Hibiya Kadan
418
525
  // checkout runs on it) — names its card fields CREDIT_NO / CREDIT_NAME /
419
526
  // SECURITY_CD / CREDIT_LIMIT_MONTH / CREDIT_LIMIT_YEAR. PAN name/id and JP
@@ -532,36 +639,6 @@ const CHECKOUT_CARD_VALUE_FIELD_SELECTORS = [
532
639
  CHECKOUT_CVV_FIELD_SELECTORS,
533
640
  CHECKOUT_CARD_NAME_FIELD_SELECTORS,
534
641
  ].join(",");
535
- // Defense-in-depth redaction set for operate_screenshot. The capture-scoped
536
- // guard refuses nonempty secrets before pixels are read; these selectors also
537
- // identify empty sensitive controls and provide rectangles that are composited
538
- // over the captured bytes. The set mirrors the card fields
539
- // fillCheckoutCardIntoFrames writes into, plus payment/type_secret markers and
540
- // other secret-shaped inputs.
541
- const SCREENSHOT_SECRET_FIELD_SELECTORS = [
542
- '[data-ts-sealed-payment="1"]',
543
- 'input[type="password" i]',
544
- 'input[autocomplete~="one-time-code" i]',
545
- 'input[name*="otp" i]',
546
- 'input[id*="otp" i]',
547
- 'input[name*="pin" i]',
548
- 'input[id*="pin" i]',
549
- ].join(",");
550
- const SCREENSHOT_REDACTION_SELECTORS = `${CHECKOUT_CARD_VALUE_FIELD_SELECTORS},${SCREENSHOT_SECRET_FIELD_SELECTORS}`;
551
- function sealedElementSemanticKeys(descriptor) {
552
- const clean = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
553
- const tag = clean(descriptor.tag);
554
- const type = clean(descriptor.type);
555
- const landmark = clean(descriptor.landmark);
556
- const label = clean(descriptor.labelText ?? descriptor.ariaLabel ?? descriptor.placeholder);
557
- return Array.from(new Set([
558
- clean(descriptor.testId) ? `test:${clean(descriptor.testId)}` : "",
559
- clean(descriptor.id) ? `id:${clean(descriptor.id)}` : "",
560
- clean(descriptor.name) ? `name:${tag}:${type}:${clean(descriptor.name)}` : "",
561
- label ? `label:${landmark}:${tag}:${type}:${label}` : "",
562
- `position:${landmark}:${tag}:${type}:${descriptor.ordinal}`,
563
- ].filter((key) => key.length > 0)));
564
- }
565
642
  // Charge-verb button labels — the click that may move money. Used by
566
643
  // submitFilledCheckout to find the charge control, and by operate_act's
567
644
  // pending-card-fill guard to recognize and cap caller-placed attempts while a
@@ -575,6 +652,112 @@ function sealedElementSemanticKeys(descriptor) {
575
652
  // where a bare noun like 購入 would otherwise swallow navigation labels such
576
653
  // as 購入手続きへ). 確定 (finalize) is deliberate — 確認 (review) must NOT match.
577
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
+ }
578
761
  export function checkoutSubmitLabel(signals) {
579
762
  return (signals.ariaLabel || signals.inputValue || signals.textContent || "").trim();
580
763
  }
@@ -1700,19 +1883,13 @@ export function extractGoogleAccountEmail(pageText) {
1700
1883
  }
1701
1884
  // Map a cookie jar to the OAuth providers that have a LIVE logged-in session.
1702
1885
  // The auth cookies that mean "signed in": GitHub → `user_session`; Google →
1703
- // any of the *SID session cookies (NID / CONSENT / 1P_JAR are set even when
1704
- // 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
1705
1889
  // google.com cookie can't pass for github. Cookie NAMES + presence only;
1706
1890
  // values are checked for non-triviality, never logged. Exported for tests.
1707
1891
  export function sessionProvidersFromCookies(cookies) {
1708
- const SIGNATURES = [
1709
- { provider: "github", host: /(^|\.)github\.com$/i, names: ["user_session"] },
1710
- {
1711
- provider: "google",
1712
- host: /(^|\.)google\.com$/i,
1713
- names: GOOGLE_LOGIN_COOKIE_MARKERS,
1714
- },
1715
- ];
1892
+ const SIGNATURES = [{ provider: "github", host: /(^|\.)github\.com$/i, names: ["user_session"] }];
1716
1893
  const live = [];
1717
1894
  for (const sig of SIGNATURES) {
1718
1895
  const present = cookies.some((c) => sig.host.test(c.domain.replace(/^\./, "")) &&
@@ -1721,6 +1898,11 @@ export function sessionProvidersFromCookies(cookies) {
1721
1898
  if (present)
1722
1899
  live.push(sig.provider);
1723
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");
1724
1906
  return live;
1725
1907
  }
1726
1908
  function isCaptchaVariant(v) {
@@ -1852,7 +2034,7 @@ export function resolveChannelBinary(channel) {
1852
2034
  // Cloudflare Turnstile's interactive challenge FAILS a Playwright/patchright
1853
2035
  // launchPersistentContext-driven Chrome and PASSES a Chrome the operator
1854
2036
  // launches itself and then attaches to over CDP — every other variable held
1855
- // 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
1856
2038
  // binary, same software-WebGL, same humanized click). The discriminator
1857
2039
  // matrix:
1858
2040
  // launchPersistentContext + CDP click → "Verification failed"
@@ -1964,41 +2146,36 @@ export async function launchCancellablePersistentContext(opts) {
1964
2146
  void lateCleanup;
1965
2147
  return { status: "cancelled", closeState: "unknown" };
1966
2148
  }
1967
- // Find an ephemeral TCP port for Chrome's --remote-debugging-port.
1968
- function findFreePort() {
1969
- return new Promise((resolve, reject) => {
1970
- const srv = createServer();
1971
- srv.on("error", reject);
1972
- srv.listen(0, "127.0.0.1", () => {
1973
- const addr = srv.address();
1974
- const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1975
- srv.close(() => (port > 0 ? resolve(port) : reject(new Error("no port"))));
1976
- });
1977
- });
1978
- }
1979
- // Poll Chrome's DevTools HTTP endpoint until it answers (the browser is up
1980
- // and accepting CDP), or the deadline passes. Returns the base endpoint URL
1981
- // connectOverCDP accepts.
1982
- async function waitForDevtools(port, deadlineMs, child) {
1983
- 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);
1984
2152
  const deadline = Date.now() + deadlineMs;
1985
2153
  let lastErr = "";
1986
2154
  while (Date.now() < deadline) {
1987
- if (child !== undefined && !childProcessIsRunning(child)) {
1988
- 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");
1989
2157
  }
1990
2158
  try {
1991
- const res = await fetch(`${base}/json/version`, { signal: AbortSignal.timeout(2_000) });
1992
- if (res.ok)
1993
- return base;
1994
- 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}`;
1995
2169
  }
1996
- catch (err) {
1997
- lastErr = err instanceof Error ? err.message : String(err);
2170
+ catch (error) {
2171
+ lastErr = error instanceof Error ? error.message : String(error);
1998
2172
  }
1999
- await new Promise((r) => setTimeout(r, 200));
2173
+ await new Promise((resolveWait) => {
2174
+ const timer = setTimeout(resolveWait, 200);
2175
+ timer.unref();
2176
+ });
2000
2177
  }
2001
- throw new Error(`Chrome DevTools endpoint never came up on ${base} (${lastErr})`);
2178
+ throw new Error(`Owned Chrome DevTools endpoint was not published (${lastErr})`);
2002
2179
  }
2003
2180
  export async function withChromeStartupLock(fn, opts = {}) {
2004
2181
  const lockDir = opts.lockDir ?? "/tmp/trusty-squire-chrome-start.lock";
@@ -2037,13 +2214,13 @@ export async function withChromeStartupLock(fn, opts = {}) {
2037
2214
  }
2038
2215
  }
2039
2216
  const selfManagedChromes = new Map();
2217
+ const ownedChromeProcessTrees = new Set();
2040
2218
  let selfManagedCleanupInstalled = false;
2041
2219
  let selfManagedTerminationSignalExitEnabled = true;
2042
- let orphanVerifierReapRan = false;
2043
- const orphanOperatorProfilesReaped = new Set();
2044
2220
  function cleanupSelfManagedChromes() {
2045
- for (const identity of selfManagedChromes.values()) {
2046
- signalProfileProcess(identity, identity.user_data_dir, "SIGKILL");
2221
+ for (const proof of ownedChromeProcessTrees) {
2222
+ signalOwnedChromeProcessTree(proof.identity, proof.processGroup, "SIGKILL", { proof });
2223
+ untrackOwnerProcess(proof.identity);
2047
2224
  }
2048
2225
  selfManagedChromes.clear();
2049
2226
  }
@@ -2054,7 +2231,20 @@ const exitForSelfManagedSignal = (code) => {
2054
2231
  const onSelfManagedSigint = () => exitForSelfManagedSignal(2);
2055
2232
  const onSelfManagedSigterm = () => exitForSelfManagedSignal(15);
2056
2233
  const onSelfManagedSighup = () => exitForSelfManagedSignal(1);
2057
- // 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.
2058
2248
  // False means another shutdown owner (the MCP server's disconnect coordinator,
2059
2249
  // or an in-flight interactive login) holds process-exit responsibility.
2060
2250
  export function isSelfManagedChromeTerminationSignalExitEnabled() {
@@ -2066,14 +2256,7 @@ export function setSelfManagedChromeTerminationSignalExitEnabled(enabled) {
2066
2256
  selfManagedTerminationSignalExitEnabled = enabled;
2067
2257
  if (!selfManagedCleanupInstalled)
2068
2258
  return;
2069
- if (enabled) {
2070
- process.once("SIGINT", onSelfManagedSigint);
2071
- process.once("SIGTERM", onSelfManagedSigterm);
2072
- }
2073
- else {
2074
- process.removeListener("SIGINT", onSelfManagedSigint);
2075
- process.removeListener("SIGTERM", onSelfManagedSigterm);
2076
- }
2259
+ synchronizeSelfManagedChromeTerminationSignalHandlers(enabled);
2077
2260
  }
2078
2261
  function installSelfManagedChromeCleanup() {
2079
2262
  if (selfManagedCleanupInstalled)
@@ -2081,28 +2264,47 @@ function installSelfManagedChromeCleanup() {
2081
2264
  selfManagedCleanupInstalled = true;
2082
2265
  process.once("exit", cleanupSelfManagedChromes);
2083
2266
  if (selfManagedTerminationSignalExitEnabled) {
2084
- process.once("SIGINT", onSelfManagedSigint);
2085
- process.once("SIGTERM", onSelfManagedSigterm);
2267
+ synchronizeSelfManagedChromeTerminationSignalHandlers(true);
2086
2268
  }
2087
- process.once("SIGHUP", onSelfManagedSighup);
2088
2269
  }
2089
- function registerSelfManagedChrome(child, profileDir) {
2270
+ function registerSelfManagedChrome(child, profileDir, processGroup = false) {
2090
2271
  installSelfManagedChromeCleanup();
2091
2272
  const identity = child.pid === undefined ? null : profileProcessIdentity(child.pid, profileDir);
2092
- if (identity !== null)
2093
- selfManagedChromes.set(identity.pid, identity);
2273
+ if (identity !== null) {
2274
+ const proof = trackOwnedChromeProcessTree(identity, processGroup);
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
+ }
2281
+ selfManagedChromes.set(identity.pid, { identity, processGroup, proof });
2282
+ }
2283
+ }
2094
2284
  child.once("exit", () => {
2095
- if (child.pid !== undefined)
2285
+ if (child.pid === undefined)
2286
+ return;
2287
+ const tracked = selfManagedChromes.get(child.pid);
2288
+ if (tracked === undefined)
2289
+ return;
2290
+ if (ownedChromeProcessTreeState(tracked.proof) === "stale") {
2291
+ releaseOwnedChromeProcessTree(tracked.proof);
2096
2292
  selfManagedChromes.delete(child.pid);
2293
+ }
2097
2294
  });
2098
2295
  return identity;
2099
2296
  }
2100
- async function waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, timeoutMs, pollMs) {
2297
+ async function waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, timeoutMs, pollMs, processGroup = false) {
2101
2298
  const deadline = Date.now() + timeoutMs;
2102
2299
  while (childProcessIsRunning(child)) {
2103
2300
  const identity = child.pid === undefined ? null : readIdentity(child.pid, profileDir);
2104
2301
  if (identity !== null) {
2105
- selfManagedChromes.set(identity.pid, identity);
2302
+ const existing = selfManagedChromes.get(identity.pid);
2303
+ const proof = existing?.identity.start_time === identity.start_time
2304
+ ? existing.proof
2305
+ : trackOwnedChromeProcessTree(identity, processGroup);
2306
+ if (proof !== null)
2307
+ selfManagedChromes.set(identity.pid, { identity, processGroup, proof });
2106
2308
  return identity;
2107
2309
  }
2108
2310
  if (Date.now() >= deadline)
@@ -2117,7 +2319,172 @@ async function waitForTrackedProfileChildIdentity(child, profileDir, readIdentit
2117
2319
  export async function resolveAttachedProfileChildIdentity(child, profileDir, identity, options = {}) {
2118
2320
  if (identity !== null || (options.platform ?? process.platform) !== "linux")
2119
2321
  return identity;
2120
- return await waitForTrackedProfileChildIdentity(child, profileDir, options.readIdentity ?? profileProcessIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS);
2322
+ return await waitForTrackedProfileChildIdentity(child, profileDir, options.readIdentity ?? profileProcessIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS, options.processGroup ?? false);
2323
+ }
2324
+ // Call this ONLY for a Chrome child spawned with detached:true. The identity
2325
+ // check protects against PID reuse, then POSIX negative-PID signalling reaches
2326
+ // Chrome's renderer/GPU/helper tree in one operation. A normal profile-root
2327
+ // signal remains the portable fallback for launchPersistentContext and Windows.
2328
+ export function signalOwnedChromeProcessTree(identity, processGroup, signal, options = {}) {
2329
+ const profileMatches = options.profileMatches ?? profileProcessMatches;
2330
+ const kill = options.kill ?? process.kill;
2331
+ const proof = options.proof ??
2332
+ captureOwnedChromeProcessTreeProof(identity, processGroup, {
2333
+ profileMatches,
2334
+ ...(options.platform === undefined ? {} : { platform: options.platform }),
2335
+ ...(options.processTreePids === undefined
2336
+ ? {}
2337
+ : { processTreePids: options.processTreePids }),
2338
+ ...(options.readBirthIdentity === undefined
2339
+ ? {}
2340
+ : { readBirthIdentity: options.readBirthIdentity }),
2341
+ });
2342
+ if (proof === null)
2343
+ return false;
2344
+ const platform = options.platform ?? process.platform;
2345
+ const memberState = options.memberState ?? processBirthIdentityState;
2346
+ const matchingMembers = proof.members.filter((member) => memberState(member) === "matching");
2347
+ const matchingGroupMember = proof.processGroup && platform !== "win32"
2348
+ ? matchingMembers.some((member) => platform !== "linux" ||
2349
+ (options.processGroupId ?? linuxProcessGroupId)(member.pid) === proof.identity.pid)
2350
+ : false;
2351
+ if (matchingGroupMember) {
2352
+ try {
2353
+ kill(-proof.identity.pid, signal);
2354
+ return true;
2355
+ }
2356
+ catch {
2357
+ // A process may exit between the proof and the signal. Fall through to
2358
+ // the root PID only while it is still identity-proven.
2359
+ }
2360
+ }
2361
+ let signalled = false;
2362
+ // Signal leaves first. This covers the Playwright persistent-context fallback
2363
+ // (including chrome-headless-shell), whose child is not a detached process
2364
+ // group leader but whose renderer tree is still rooted at the identity-proven
2365
+ // browser PID.
2366
+ for (const member of [...proof.members].reverse()) {
2367
+ if (memberState(member) !== "matching")
2368
+ continue;
2369
+ try {
2370
+ kill(member.pid, signal);
2371
+ signalled = true;
2372
+ }
2373
+ catch {
2374
+ // A child can naturally exit while the tree is being walked.
2375
+ }
2376
+ }
2377
+ return signalled;
2378
+ }
2379
+ export function captureOwnedChromeProcessTreeProof(identity, processGroup, options = {}) {
2380
+ const profileMatches = options.profileMatches ?? profileProcessMatches;
2381
+ if (!profileMatches(identity, identity.user_data_dir))
2382
+ return null;
2383
+ const platform = options.platform ?? process.platform;
2384
+ const pids = platform === "linux"
2385
+ ? (options.processTreePids ?? linuxProcessTreePids)(identity.pid)
2386
+ : [identity.pid];
2387
+ const readBirthIdentity = options.readBirthIdentity ?? processBirthIdentity;
2388
+ const members = pids.flatMap((pid) => {
2389
+ if (pid === identity.pid)
2390
+ return [{ pid, start_time: identity.start_time }];
2391
+ const member = readBirthIdentity(pid);
2392
+ return member === null ? [] : [member];
2393
+ });
2394
+ if (!members.some((member) => member.pid === identity.pid)) {
2395
+ members.unshift({ pid: identity.pid, start_time: identity.start_time });
2396
+ }
2397
+ return { identity, processGroup, members };
2398
+ }
2399
+ function trackOwnedChromeProcessTree(identity, processGroup) {
2400
+ installSelfManagedChromeCleanup();
2401
+ const marker = operatorBrowserProcessMarker(identity.pid);
2402
+ const trackedIdentity = marker === null ? identity : { ...identity, process_marker: marker };
2403
+ const proof = captureOwnedChromeProcessTreeProof(trackedIdentity, processGroup);
2404
+ if (proof === null)
2405
+ return null;
2406
+ ownedChromeProcessTrees.add(proof);
2407
+ trackOwnerProcess(proof.identity);
2408
+ return proof;
2409
+ }
2410
+ function releaseOwnedChromeProcessTree(proof) {
2411
+ if (proof === null)
2412
+ return;
2413
+ ownedChromeProcessTrees.delete(proof);
2414
+ untrackOwnerProcess(proof.identity);
2415
+ }
2416
+ export function ownedChromeProcessTreeState(proof, options = {}) {
2417
+ const memberState = options.memberState ?? processBirthIdentityState;
2418
+ let sawUnknown = false;
2419
+ for (const member of proof.members) {
2420
+ const state = memberState(member);
2421
+ if (state === "matching")
2422
+ return "matching";
2423
+ if (state === "unknown")
2424
+ sawUnknown = true;
2425
+ }
2426
+ return sawUnknown ? "unknown" : "stale";
2427
+ }
2428
+ function linuxProcessGroupId(pid) {
2429
+ try {
2430
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
2431
+ const closeParen = stat.lastIndexOf(")");
2432
+ if (closeParen < 0)
2433
+ return null;
2434
+ const processGroupId = Number(stat
2435
+ .slice(closeParen + 2)
2436
+ .trim()
2437
+ .split(/\s+/)[2]);
2438
+ return Number.isSafeInteger(processGroupId) ? processGroupId : null;
2439
+ }
2440
+ catch {
2441
+ return null;
2442
+ }
2443
+ }
2444
+ function linuxProcessTreePids(rootPid) {
2445
+ try {
2446
+ const childrenByParent = new Map();
2447
+ for (const entry of readdirSync("/proc")) {
2448
+ if (!/^\d+$/.test(entry))
2449
+ continue;
2450
+ const pid = Number(entry);
2451
+ try {
2452
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
2453
+ const closeParen = stat.lastIndexOf(")");
2454
+ if (closeParen < 0)
2455
+ continue;
2456
+ const parentPid = Number(stat
2457
+ .slice(closeParen + 2)
2458
+ .trim()
2459
+ .split(/\s+/)[1]);
2460
+ if (!Number.isSafeInteger(parentPid))
2461
+ continue;
2462
+ const children = childrenByParent.get(parentPid) ?? [];
2463
+ children.push(pid);
2464
+ childrenByParent.set(parentPid, children);
2465
+ }
2466
+ catch {
2467
+ // Processes leave /proc constantly; a partial tree is still safer than
2468
+ // abandoning the profile-root browser after a failed close.
2469
+ }
2470
+ }
2471
+ const pids = [];
2472
+ const pending = [rootPid];
2473
+ const seen = new Set();
2474
+ while (pending.length > 0) {
2475
+ const pid = pending.pop();
2476
+ if (seen.has(pid))
2477
+ continue;
2478
+ seen.add(pid);
2479
+ pids.push(pid);
2480
+ for (const child of childrenByParent.get(pid) ?? [])
2481
+ pending.push(child);
2482
+ }
2483
+ return pids;
2484
+ }
2485
+ catch {
2486
+ return [rootPid];
2487
+ }
2121
2488
  }
2122
2489
  export async function terminateTrackedProfileChild(child, profileDir, options = {}) {
2123
2490
  const readIdentity = options.readIdentity ?? profileProcessIdentity;
@@ -2131,10 +2498,20 @@ export async function terminateTrackedProfileChild(child, profileDir, options =
2131
2498
  if (identity === null && (options.platform ?? process.platform) !== "linux")
2132
2499
  return null;
2133
2500
  while (childProcessIsRunning(child)) {
2134
- identity ??= await waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS);
2501
+ identity ??= await waitForTrackedProfileChildIdentity(child, profileDir, readIdentity, options.identityTimeoutMs ?? PROFILE_IDENTITY_PROOF_TIMEOUT_MS, options.identityPollMs ?? PROFILE_IDENTITY_POLL_MS, options.processGroup ?? false);
2135
2502
  if (identity === null)
2136
2503
  break;
2137
- selfManagedChromes.set(identity.pid, identity);
2504
+ const existing = selfManagedChromes.get(identity.pid);
2505
+ const proof = existing?.identity.start_time === identity.start_time
2506
+ ? existing.proof
2507
+ : trackOwnedChromeProcessTree(identity, options.processGroup ?? false);
2508
+ if (proof !== null) {
2509
+ selfManagedChromes.set(identity.pid, {
2510
+ identity,
2511
+ processGroup: options.processGroup ?? false,
2512
+ proof,
2513
+ });
2514
+ }
2138
2515
  const terminated = terminate(identity, profileDir);
2139
2516
  if (!terminated) {
2140
2517
  identity = null;
@@ -2147,101 +2524,8 @@ export async function terminateTrackedProfileChild(child, profileDir, options =
2147
2524
  });
2148
2525
  }
2149
2526
  }
2150
- if (child.pid !== undefined)
2151
- selfManagedChromes.delete(child.pid);
2152
2527
  return identity;
2153
2528
  }
2154
- // Stale verifier/operator browsers are the expensive leak mode: if the MCP process dies
2155
- // mid-verify, self-launched Chrome survives as PPID=1 with a
2156
- // ~/.trusty-squire/profiles/verify-* or ~/.trusty-squire/chrome-profile
2157
- // user-data-dir. It keeps the profile lock,
2158
- // burns memory, and may leave defunct helper children. A live verifier should
2159
- // never be parented to init, so these are safe to reap at the next browser
2160
- // startup. Best-effort and Linux-only; failure must not block signups.
2161
- export function matchesReapableBrowserArgs(args, profileDirs, includeVerifier) {
2162
- return reapableBrowserProfile(args, profileDirs, includeVerifier) !== null;
2163
- }
2164
- function reapableBrowserProfile(args, profileDirs, includeVerifier) {
2165
- if (!/(?:chrome|chromium)/i.test(args))
2166
- return null;
2167
- const configuredProfiles = new Map(profileDirs.map((profileDir) => [profileDir, profilePathIdentity(profileDir)]));
2168
- const match = /--user-data-dir=(?:"([^"]+)"|'([^']+)'|([^\s]+))(?=\s|$)/.exec(args);
2169
- const candidate = match?.[1] ?? match?.[2] ?? match?.[3];
2170
- if (candidate !== undefined) {
2171
- const identity = profilePathIdentity(candidate);
2172
- if ([...configuredProfiles.values()].includes(identity))
2173
- return identity;
2174
- if (includeVerifier && /[/\\]\.trusty-squire[/\\]profiles[/\\]verify-[^/\\]+$/.test(identity)) {
2175
- return identity;
2176
- }
2177
- }
2178
- for (const [profileDir, identity] of configuredProfiles) {
2179
- for (const exactPath of new Set([profileDir, identity])) {
2180
- const variants = [exactPath, `"${exactPath}"`, `'${exactPath}'`];
2181
- if (variants.some((variant) => {
2182
- const option = `--user-data-dir=${variant}`;
2183
- const offset = args.indexOf(option);
2184
- return (offset >= 0 &&
2185
- /\s|$/.test(args.slice(offset + option.length, offset + option.length + 1)));
2186
- })) {
2187
- return identity;
2188
- }
2189
- }
2190
- }
2191
- return null;
2192
- }
2193
- export function claimOrphanBrowserReapScope(profileDir) {
2194
- const includeVerifier = !orphanVerifierReapRan;
2195
- const profileDirs = [...new Set([CHROME_PROFILE_DIR, profileDir])].filter((candidate) => !orphanOperatorProfilesReaped.has(candidate));
2196
- if (!includeVerifier && profileDirs.length === 0)
2197
- return null;
2198
- orphanVerifierReapRan = true;
2199
- for (const candidate of profileDirs)
2200
- orphanOperatorProfilesReaped.add(candidate);
2201
- return { includeVerifier, profileDirs };
2202
- }
2203
- function reapOrphanedBrowsersOnce(profileDir) {
2204
- if (process.platform !== "linux")
2205
- return;
2206
- const scope = claimOrphanBrowserReapScope(profileDir);
2207
- if (scope === null)
2208
- return;
2209
- let rows = "";
2210
- try {
2211
- rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], {
2212
- stdio: ["ignore", "pipe", "ignore"],
2213
- }).toString("utf8");
2214
- }
2215
- catch {
2216
- return;
2217
- }
2218
- const identities = [];
2219
- for (const line of rows.split("\n")) {
2220
- const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
2221
- if (match === null)
2222
- continue;
2223
- const pid = Number(match[1]);
2224
- const ppid = Number(match[2]);
2225
- const args = match[3] ?? "";
2226
- const expectedProfile = reapableBrowserProfile(args, scope.profileDirs, scope.includeVerifier);
2227
- if (Number.isFinite(pid) && ppid === 1 && expectedProfile !== null) {
2228
- const identity = profileProcessIdentity(pid, expectedProfile);
2229
- if (identity !== null)
2230
- identities.push(identity);
2231
- }
2232
- }
2233
- if (identities.length === 0)
2234
- return;
2235
- console.error(`[operator] reaping ${identities.length} orphaned Chrome process(es)`);
2236
- for (const identity of identities) {
2237
- signalProfileProcess(identity, identity.user_data_dir, "SIGTERM");
2238
- }
2239
- setTimeout(() => {
2240
- for (const identity of identities) {
2241
- signalProfileProcess(identity, identity.user_data_dir, "SIGKILL");
2242
- }
2243
- }, 2_000).unref();
2244
- }
2245
2529
  // Classify an anti-bot interstitial page from its (title + body) text.
2246
2530
  // `onInterstitial` matches the static Cloudflare/Turnstile challenge copy.
2247
2531
  // `verificationPassed` is the signal the challenge SUCCEEDED — but
@@ -2288,122 +2572,45 @@ export function stripCloudflareChallengeParams(rawUrl) {
2288
2572
  export function childProcessIsRunning(child) {
2289
2573
  return child !== null && child.exitCode === null && child.signalCode === null;
2290
2574
  }
2291
- export async function attachSelfManagedLoginContext(endpoint, child, profileDir, identity, options = {}) {
2292
- let browser = null;
2293
- try {
2294
- browser = await (options.launcher ?? getChromium()).connectOverCDP(endpoint);
2295
- const context = browser.contexts()[0];
2296
- if (context === undefined) {
2297
- throw new Error("self-launched login Chrome exposed no default browser context");
2298
- }
2299
- return { browser, context };
2300
- }
2301
- catch (error) {
2302
- await browser?.close().catch(() => undefined);
2303
- const terminateChild = options.terminateChild ??
2304
- ((ownedChild, ownedProfileDir, ownedIdentity) => terminateTrackedProfileChild(ownedChild, ownedProfileDir, {
2305
- identity: ownedIdentity,
2306
- }));
2307
- await terminateChild(child, profileDir, identity);
2308
- throw error;
2309
- }
2310
- }
2311
2575
  function profileCollisionFromStderr(stderr) {
2312
2576
  return /ProcessSingleton|SingletonLock|profile.*in use/i.test(stderr)
2313
2577
  ? new ProfileBusyError(PROFILE_BUSY_MESSAGE)
2314
2578
  : null;
2315
2579
  }
2316
- // Self-launch Chrome + connectOverCDP for the INTERACTIVE login (connect /
2317
- // `mcp login`), instead of Playwright's launchPersistentContext.
2318
- //
2319
- // This is the STATE.md 2026-06-12 finding — the same launcher tell that fails
2320
- // Cloudflare Turnstile from a launchPersistentContext-driven Chrome and passes
2321
- // from a self-launched one — ported to the login path. BrowserController (the
2322
- // signup path) already migrated; the connect login was the last consumer of
2323
- // the detectable launcher. Kept STANDALONE (not a BrowserController method) so
2324
- // the working signup path is untouched.
2580
+ // The signal that quits the plain login browser.
2325
2581
  //
2326
- // Persistent profile is preserved: --user-data-dir=profileDir means the
2327
- // provider session (Google/GitHub cookies) still lands in the bot's profile,
2328
- // which the connect flow needs to seed for later Gmail-reading / OAuth signups.
2329
- export async function launchSelfManagedLoginContext(params) {
2330
- let child = null;
2331
- let childIdentity = null;
2332
- const endpoint = await withChromeStartupLock(async () => {
2333
- const port = await findFreePort();
2334
- clearStaleSingletonLock(params.profileDir);
2335
- const argv = [
2336
- `--remote-debugging-port=${port}`,
2337
- "--remote-debugging-address=127.0.0.1",
2338
- `--user-data-dir=${params.profileDir}`,
2339
- "--no-first-run",
2340
- "--no-default-browser-check",
2341
- "--password-store=basic",
2342
- "--window-position=0,0",
2343
- `--window-size=${params.window.width},${params.window.height}`,
2344
- "--lang=en-US",
2345
- ...(params.extraArgs ?? []),
2346
- ...(params.proxyServer !== null ? [`--proxy-server=${params.proxyServer}`] : []),
2347
- // NB: we build argv ourselves, so Playwright's --enable-automation
2348
- // (and the rest of its launch instrumentation — the actual Turnstile
2349
- // tell) is never added. That is the whole point of self-launching.
2350
- params.appMode ? `--app=${params.initialUrl}` : params.initialUrl,
2351
- ];
2352
- const spawned = spawn(params.binary, argv, {
2353
- env: params.env,
2354
- stdio: ["ignore", "ignore", "pipe"],
2355
- });
2356
- child = spawned;
2357
- childIdentity = registerSelfManagedChrome(spawned, params.profileDir);
2358
- let chromeStderr = "";
2359
- spawned.stderr?.on("data", (chunk) => {
2360
- chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
2361
- });
2362
- try {
2363
- const endpoint = await waitForDevtools(port, 30_000, spawned);
2364
- childIdentity = await resolveAttachedProfileChildIdentity(spawned, params.profileDir, childIdentity);
2365
- if (process.platform === "linux" && childIdentity === null) {
2366
- throw new Error("self-launched login Chrome exited before identity was proven");
2367
- }
2368
- return endpoint;
2369
- }
2370
- catch (err) {
2371
- childIdentity = await terminateTrackedProfileChild(spawned, params.profileDir, {
2372
- identity: childIdentity,
2373
- });
2374
- const detail = chromeStderr.trim();
2375
- const collision = profileCollisionFromStderr(detail);
2376
- if (collision !== null)
2377
- throw collision;
2378
- throw new Error(`${err instanceof Error ? err.message : String(err)}` +
2379
- `${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
2380
- }
2381
- }, { deadlineMs: 0 });
2382
- if (child === null) {
2383
- throw new Error("self-launched login Chrome lost its process handle");
2384
- }
2385
- const { browser, context } = await attachSelfManagedLoginContext(endpoint, child, params.profileDir, childIdentity);
2386
- let torn = false;
2387
- const teardown = async () => {
2388
- if (torn)
2389
- return;
2390
- torn = true;
2391
- // Disconnect the CDP browser first; over connectOverCDP this leaves the
2392
- // real Chrome running, so kill the child explicitly (SIGTERM, then a
2393
- // hard SIGKILL after a short grace) to avoid the zombie-chrome leak.
2394
- await browser.close();
2395
- if (child !== null && childIdentity !== null) {
2396
- signalProfileProcess(childIdentity, params.profileDir, "SIGTERM");
2397
- }
2398
- };
2399
- const forceTeardown = () => {
2400
- if (childIdentity !== null) {
2401
- signalProfileProcess(childIdentity, params.profileDir, "SIGKILL");
2402
- selfManagedChromes.delete(childIdentity.pid);
2403
- }
2404
- reapProfileHolderIfOwned(params.profileDir, childIdentity);
2405
- };
2406
- 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();
2407
2614
  }
2408
2615
  // Launch a TRULY PLAIN Chrome for the interactive connect claim — NO
2409
2616
  // `--remote-debugging-port`, NO `connectOverCDP`, NO Playwright attach at all.
@@ -2416,93 +2623,146 @@ export async function launchSelfManagedLoginContext(params) {
2416
2623
  // though the same CDP browser passes a DIRECT accounts.google.com sign-in. The
2417
2624
  // tell is the CDP attachment itself (NOT the launcher, NOT the flags, NOT
2418
2625
  // `navigator.webdriver` — all separately ruled out). The connect claim doesn't
2419
- // need to drive the browser: the USER signs in over noVNC, completion is read
2420
- // from the API (`installPoll`), and provider seeding is read from the profile's
2421
- // SQLite cookie store (`profileHasProviderCookies`). So we spawn Chrome and
2422
- // 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.
2423
2629
  //
2424
2630
  // Persistent profile is preserved (--user-data-dir=profileDir) so the Google/
2425
2631
  // GitHub session still lands in the bot's profile for later signups.
2426
2632
  export async function launchPlainLoginBrowser(params) {
2427
2633
  let child = null;
2428
2634
  let childIdentity = null;
2429
- await withChromeStartupLock(async () => {
2430
- clearStaleSingletonLock(params.profileDir);
2431
- const argv = [
2432
- `--user-data-dir=${params.profileDir}`,
2433
- "--no-first-run",
2434
- "--no-default-browser-check",
2435
- "--password-store=basic",
2436
- "--window-position=0,0",
2437
- `--window-size=${params.window.width},${params.window.height}`,
2438
- "--lang=en-US",
2439
- ...(params.extraArgs ?? []),
2440
- ...(params.proxyServer !== null ? [`--proxy-server=${params.proxyServer}`] : []),
2441
- `--app=${params.url}`,
2442
- ];
2443
- const spawned = spawn(params.binary, argv, {
2444
- env: params.env,
2445
- stdio: ["ignore", "ignore", "pipe"],
2446
- });
2447
- child = spawned;
2448
- childIdentity = registerSelfManagedChrome(spawned, params.profileDir);
2449
- let chromeStderr = "";
2450
- spawned.stderr?.on("data", (chunk) => {
2451
- chromeStderr = (chromeStderr + chunk.toString("utf8")).slice(-4_000);
2452
- });
2453
- // Give Chrome a moment to actually come up (or die). Unlike the CDP path
2454
- // there is no devtools endpoint to poll — but a crash-on-launch (bad
2455
- // profile, missing lib) should surface here, not 15min later as a blank
2456
- // noVNC. If the process is already dead, throw with its stderr.
2457
- await new Promise((r) => setTimeout(r, 1_200));
2458
- childIdentity ??=
2459
- spawned.pid === undefined ? null : profileProcessIdentity(spawned.pid, params.profileDir);
2460
- childIdentity = await resolveAttachedProfileChildIdentity(spawned, params.profileDir, childIdentity);
2461
- if (childIdentity !== null)
2462
- selfManagedChromes.set(childIdentity.pid, childIdentity);
2463
- if (!childProcessIsRunning(spawned)) {
2464
- reapProfileHolderIfOwned(params.profileDir, childIdentity);
2465
- const detail = chromeStderr.trim();
2466
- const collision = profileCollisionFromStderr(detail);
2467
- if (collision !== null)
2468
- throw collision;
2469
- const termination = spawned.exitCode !== null
2470
- ? `code ${spawned.exitCode}`
2471
- : `signal ${spawned.signalCode ?? "unknown"}`;
2472
- throw new Error(`plain login Chrome exited immediately (${termination})` +
2473
- `${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
2474
- }
2475
- if (process.platform === "linux" && childIdentity === null) {
2476
- throw new Error("plain login Chrome identity could not be proven");
2477
- }
2478
- }, { deadlineMs: 0 });
2479
- let torn = false;
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}` : ""}`);
2711
+ }
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;
2480
2723
  const forceTeardown = () => {
2724
+ markLocalBrowserLaunchTerminal(child);
2481
2725
  if (childIdentity !== null) {
2482
- signalProfileProcess(childIdentity, params.profileDir, "SIGKILL");
2483
- selfManagedChromes.delete(childIdentity.pid);
2726
+ const tracked = selfManagedChromes.get(childIdentity.pid);
2727
+ signalOwnedChromeProcessTree(childIdentity, false, "SIGKILL", {
2728
+ ...(tracked === undefined ? {} : { proof: tracked.proof }),
2729
+ });
2730
+ }
2731
+ else if (childProcessIsRunning(child)) {
2732
+ child?.kill("SIGKILL");
2484
2733
  }
2485
2734
  reapProfileHolderIfOwned(params.profileDir, childIdentity);
2486
2735
  };
2487
- const teardown = async () => {
2488
- if (torn)
2489
- return;
2490
- torn = true;
2491
- if (child !== null && childIdentity !== null) {
2492
- signalProfileProcess(childIdentity, params.profileDir, "SIGTERM");
2493
- }
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;
2494
2755
  };
2495
2756
  return {
2496
2757
  teardown,
2497
2758
  forceTeardown,
2498
2759
  isRunning: () => childProcessIsRunning(child),
2499
2760
  identity: childIdentity,
2761
+ marker: launchMarker,
2500
2762
  };
2501
2763
  }
2502
2764
  export class BrowserController {
2503
- // The persistent browser context. Persistent (launchPersistentContext)
2504
- // rather than an ephemeral context so the profile carries the user's
2505
- // Google session across runs — see profile.ts / google-login.ts.
2765
+ // A persistent browser context backed by the user's real Chrome profile.
2506
2766
  context = null;
2507
2767
  page = null;
2508
2768
  checkoutCardGroupScope;
@@ -2511,31 +2771,43 @@ export class BrowserController {
2511
2771
  observedPaymentInstrumentMismatch;
2512
2772
  checkoutSubmitSequence = 0;
2513
2773
  clickDispatchSequence = 0;
2514
- sealedDocumentSequence = 0;
2515
- sealedDocuments = new Map();
2774
+ mainDocumentSequence = 0;
2775
+ mainDocumentIdentities = new WeakMap();
2776
+ trackedMainDocumentPages = new WeakSet();
2516
2777
  // The page start() configured with the controller's navigation/captcha
2517
- // 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
2518
2779
  // reuse must always restore this original page rather than adopting a popup
2519
2780
  // whose lifecycle handlers were never installed.
2520
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 = [];
2521
2789
  // Self-launch path (Turnstile-safe; see selfLaunchEnabled). When we spawn
2522
2790
  // Chrome ourselves and attach over CDP, these hold the child process and
2523
2791
  // the connected Browser so close() can tear both down.
2524
2792
  childChrome = null;
2525
2793
  childChromeIdentity = null;
2794
+ childChromeProcessGroup = false;
2795
+ ownedDisplayRig = null;
2796
+ ownedChromeProcessTreeProof = null;
2797
+ operatorProcessMarker = null;
2798
+ ownerLaunchTracked = false;
2526
2799
  cdpBrowser = null;
2527
2800
  // True once a local browser context launched this session.
2528
2801
  launchedContext = false;
2529
2802
  launchedProfileHolderIdentity = null;
2530
- profileOperationLease = null;
2531
2803
  startPromise = null;
2532
2804
  closePromise = null;
2533
2805
  startCancellationRequested = false;
2534
2806
  startLaunchCommitted = false;
2535
2807
  startSettled = false;
2536
2808
  persistentFallbackLaunchInFlight = false;
2809
+ persistentFallbackOwnershipMonitor = null;
2537
2810
  persistentFallbackCancellationState = null;
2538
- profilePoolReusable = true;
2539
2811
  resolveStartCancellation = null;
2540
2812
  startCancellation = new Promise((resolveCancellation) => {
2541
2813
  this.resolveStartCancellation = resolveCancellation;
@@ -2563,6 +2835,7 @@ export class BrowserController {
2563
2835
  // siblings and fails-fast on genuinely out-of-scope in-page API calls. When
2564
2836
  // null/never set (harness replay, non-session use) the guard is inert.
2565
2837
  hostScopeAllowedHostsProvider = null;
2838
+ operationScopedAllowedHosts = new Map();
2566
2839
  hostScopeGuardInstallation = null;
2567
2840
  // Feed the current session's allowed hosts to the request-scope guard. Read
2568
2841
  // lazily per request, so allow_host / auto-widen updates take effect without
@@ -2571,7 +2844,7 @@ export class BrowserController {
2571
2844
  // sessions, never for harness/replay or non-session browsers.
2572
2845
  async setHostScopeAllowedHosts(provider, siblingDomainProvider = provider) {
2573
2846
  this.hostScopeAllowedHostsProvider = () => ({
2574
- allowedHosts: provider(),
2847
+ allowedHosts: [...provider(), ...this.operationScopedAllowedHosts.keys()],
2575
2848
  siblingDomainHosts: siblingDomainProvider(),
2576
2849
  });
2577
2850
  this.hostScopeGuardInstallation ??= this.installHostScopeGuard().catch((error) => {
@@ -2580,6 +2853,24 @@ export class BrowserController {
2580
2853
  });
2581
2854
  await this.hostScopeGuardInstallation;
2582
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
+ }
2583
2874
  // Defect-A fail-fast request-scope guard. Only XHR/fetch subresource API
2584
2875
  // calls are scope-guarded; page-load resources (scripts/styles/images/frames)
2585
2876
  // always continue, so a legitimate render is never broken by the guard. A
@@ -2622,58 +2913,125 @@ export class BrowserController {
2622
2913
  oauthProductPage = null;
2623
2914
  oauthProviderPage = null;
2624
2915
  oauthProviderPageClosed = false;
2625
- // Deep-investigation instrumentation (UNIVERSAL_BOT_OAUTH_DEBUG): a ring
2626
- // buffer of OAuth/SSO-relevant network responses, so we can see WHY a Clerk/
2627
- // Stytch SSO callback fails to persist a session (cookie not set, FAPI
2628
- // rejection, etc.) without guessing. Off by default; zero cost when unset.
2629
- oauthNetLog = [];
2630
- oauthNetListenerAttached = false;
2631
- // F13 — on-demand Xvfb. Set when start() determined the host has no
2632
- // display surface but Xvfb is available, so Chrome can run with
2633
- // `headless: false` against a virtual display (Cloudflare/Stytch et
2634
- // al. detect Chromium-headless and block their signup forms). Torn
2635
- // down by close().
2636
- xvfb = null;
2637
- // F13 — which launch path start() took. Surfaced via .launchMode so
2638
- // the agent can push it into the run's step trail and we can see
2639
- // (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.
2640
2918
  launchedMode = "unknown";
2641
2919
  get launchMode() {
2642
2920
  return this.launchedMode;
2643
2921
  }
2644
2922
  constructor(opts = {}) {
2645
2923
  this.humanize = opts.humanize ?? true;
2646
- this.profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
2647
- this.profileOperationLease = opts.profileOperationLease ?? null;
2924
+ this.profileDir = opts.profileDir ?? "";
2648
2925
  this.proxyOverride =
2649
2926
  opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
2650
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
+ }
2651
2954
  /** Attach normal controller behavior to a harness-owned Playwright page. */
2652
2955
  static fromHarnessPage(page) {
2653
2956
  const controller = new BrowserController({ humanize: false });
2654
2957
  controller.context = page.context();
2655
2958
  controller.page = page;
2656
2959
  controller.primaryPage = page;
2960
+ controller.trackMainDocument(page);
2961
+ controller.trackOpenedTabs(page.context());
2657
2962
  controller.harnessAttachedPage = true;
2658
2963
  controller.launchedMode = "headless";
2659
2964
  return controller;
2660
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
+ }
2661
2980
  // Per-launch egress override. null means direct egress. Explicit overrides
2662
2981
  // are never subject to host-network classification.
2663
2982
  proxyOverride;
2664
- // Warm reuse is deliberately narrow: a browser belongs to exactly one
2665
- // persistent profile and one explicit egress override. Undefined/blank proxy
2666
- // values both mean direct egress.
2667
- matchesLaunchOptions(opts) {
2668
- const profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
2669
- const proxyUrl = opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
2670
- return this.profileDir === profileDir && this.proxyOverride === proxyUrl;
2671
- }
2672
- /** Identity persisted by the operator profile lease for owner-safe crash recovery. */
2673
- operatorWorkerIdentity() {
2674
- return this.childChromeIdentity ?? this.launchedProfileHolderIdentity;
2675
- }
2676
- // Required health gate for a warm browser. BrowserContext alone is not a
2983
+ operatorBrowserMarker() {
2984
+ this.operatorProcessMarker ??= createOperatorBrowserMarker();
2985
+ return this.operatorProcessMarker;
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
+ }
3008
+ adoptOwnedChromeProcessTree(identity, processGroup) {
3009
+ if (this.ownedChromeProcessTreeProof?.identity.pid === identity.pid &&
3010
+ this.ownedChromeProcessTreeProof.identity.start_time === identity.start_time) {
3011
+ return this.ownedChromeProcessTreeProof;
3012
+ }
3013
+ const tracked = selfManagedChromes.get(identity.pid);
3014
+ const proof = tracked?.identity.start_time === identity.start_time
3015
+ ? tracked.proof
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
+ }
3023
+ if (proof !== null)
3024
+ this.ownedChromeProcessTreeProof = proof;
3025
+ return proof;
3026
+ }
3027
+ signalCurrentSelfManagedChrome(identity, signal) {
3028
+ return signalOwnedChromeProcessTree(identity, this.childChromeProcessGroup, signal, {
3029
+ ...(this.ownedChromeProcessTreeProof === null
3030
+ ? {}
3031
+ : { proof: this.ownedChromeProcessTreeProof }),
3032
+ });
3033
+ }
3034
+ // Required health gate for a live session browser. BrowserContext alone is not a
2677
3035
  // sufficient signal: a dead CDP transport can leave stale JS objects behind.
2678
3036
  isConnected() {
2679
3037
  const browser = this.cdpBrowser ?? this.context?.browser() ?? null;
@@ -2740,13 +3098,12 @@ export class BrowserController {
2740
3098
  }
2741
3099
  return ctx;
2742
3100
  }
2743
- const endpoint = await withChromeStartupLock(async () => {
2744
- this.throwIfStartCancelled();
2745
- const port = await findFreePort();
3101
+ const endpoint = await (async () => {
2746
3102
  this.throwIfStartCancelled();
2747
3103
  clearStaleSingletonLock(this.profileDir);
3104
+ rmSync(join(this.profileDir, DEVTOOLS_ACTIVE_PORT_FILE), { force: true });
2748
3105
  const argv = [
2749
- `--remote-debugging-port=${port}`,
3106
+ "--remote-debugging-port=0",
2750
3107
  "--remote-debugging-address=127.0.0.1",
2751
3108
  `--user-data-dir=${this.profileDir}`,
2752
3109
  "--no-first-run",
@@ -2757,16 +3114,23 @@ export class BrowserController {
2757
3114
  "--lang=en-US",
2758
3115
  ...params.args,
2759
3116
  ...(params.proxy !== null ? [`--proxy-server=${params.proxy.server}`] : []),
2760
- ...(params.headless ? ["--headless=new"] : []),
2761
3117
  "about:blank",
2762
3118
  ];
2763
3119
  this.commitProfileLaunch();
2764
- const child = spawn(params.binary, argv, {
3120
+ const child = spawnLocalBrowser(params.binary, argv, this.profileDir, {
2765
3121
  env: params.env,
2766
3122
  stdio: ["ignore", "ignore", "pipe"],
3123
+ // A dedicated process group gives the session a single, identity-
3124
+ // proven teardown target for Chrome plus every renderer/GPU helper.
3125
+ detached: process.platform !== "win32",
3126
+ marker: this.operatorBrowserMarker(),
2767
3127
  });
2768
3128
  this.childChrome = child;
2769
- this.childChromeIdentity = registerSelfManagedChrome(child, this.profileDir);
3129
+ this.childChromeProcessGroup = process.platform !== "win32";
3130
+ this.childChromeIdentity = registerSelfManagedChrome(child, this.profileDir, this.childChromeProcessGroup);
3131
+ if (this.childChromeIdentity !== null) {
3132
+ this.adoptOwnedChromeProcessTree(this.childChromeIdentity, this.childChromeProcessGroup);
3133
+ }
2770
3134
  let chromeStderr = "";
2771
3135
  let chromeExit = "";
2772
3136
  child.stderr?.on("data", (chunk) => {
@@ -2780,11 +3144,14 @@ export class BrowserController {
2780
3144
  throw new Error("BrowserController start cancelled");
2781
3145
  }
2782
3146
  try {
2783
- const endpoint = await waitForDevtools(port, 30_000);
2784
- this.childChromeIdentity = await resolveAttachedProfileChildIdentity(child, this.profileDir, this.childChromeIdentity);
3147
+ const endpoint = await waitForOwnedDevtoolsEndpoint(this.profileDir, 30_000, child);
3148
+ this.childChromeIdentity = await resolveAttachedProfileChildIdentity(child, this.profileDir, this.childChromeIdentity, { processGroup: this.childChromeProcessGroup });
2785
3149
  if (process.platform === "linux" && this.childChromeIdentity === null) {
2786
3150
  throw new Error("self-launched Chrome exited before identity was proven");
2787
3151
  }
3152
+ if (this.childChromeIdentity !== null) {
3153
+ this.adoptOwnedChromeProcessTree(this.childChromeIdentity, this.childChromeProcessGroup);
3154
+ }
2788
3155
  return endpoint;
2789
3156
  }
2790
3157
  catch (err) {
@@ -2792,14 +3159,25 @@ export class BrowserController {
2792
3159
  profileProcessMatches(this.childChromeIdentity, this.profileDir);
2793
3160
  this.childChromeIdentity = await terminateTrackedProfileChild(child, this.profileDir, {
2794
3161
  identity: this.childChromeIdentity,
3162
+ terminate: (identity, profileDir) => {
3163
+ const signalled = signalOwnedChromeProcessTree(identity, this.childChromeProcessGroup, "SIGKILL", {
3164
+ ...(this.ownedChromeProcessTreeProof === null
3165
+ ? {}
3166
+ : { proof: this.ownedChromeProcessTreeProof }),
3167
+ });
3168
+ reapProfileHolderIfOwned(profileDir, identity);
3169
+ return signalled;
3170
+ },
3171
+ processGroup: this.childChromeProcessGroup,
2795
3172
  });
2796
3173
  this.childChrome = null;
2797
3174
  this.childChromeIdentity = null;
3175
+ this.childChromeProcessGroup = false;
2798
3176
  const detail = chromeStderr.trim();
2799
3177
  throw new Error(`${err instanceof Error ? err.message : String(err)}; Chrome pid=${child.pid ?? "unknown"} alive=${alive ? 1 : 0}` +
2800
3178
  `${chromeExit}${detail.length > 0 ? `; Chrome stderr: ${detail}` : ""}`);
2801
3179
  }
2802
- }, { deadlineMs: 0 });
3180
+ })();
2803
3181
  // Use the patchright launcher's connectOverCDP — it's the exact path the
2804
3182
  // falsification experiment validated (its connect avoids Runtime.enable,
2805
3183
  // which a plain attach would emit). The anti-detection that matters here
@@ -2816,10 +3194,21 @@ export class BrowserController {
2816
3194
  async cancelSpawnedSelfManagedChrome(child) {
2817
3195
  this.childChromeIdentity = await terminateTrackedProfileChild(child, this.profileDir, {
2818
3196
  identity: this.childChromeIdentity,
3197
+ terminate: (identity, profileDir) => {
3198
+ const signalled = signalOwnedChromeProcessTree(identity, this.childChromeProcessGroup, "SIGKILL", {
3199
+ ...(this.ownedChromeProcessTreeProof === null
3200
+ ? {}
3201
+ : { proof: this.ownedChromeProcessTreeProof }),
3202
+ });
3203
+ reapProfileHolderIfOwned(profileDir, identity);
3204
+ return signalled;
3205
+ },
3206
+ processGroup: this.childChromeProcessGroup,
2819
3207
  });
2820
3208
  if (this.childChrome === child)
2821
3209
  this.childChrome = null;
2822
3210
  this.childChromeIdentity = null;
3211
+ this.childChromeProcessGroup = false;
2823
3212
  }
2824
3213
  // Resource blocking for speed (BOT_BLOCK_RESOURCES, default OFF). Aborts
2825
3214
  // image/media/font requests + known analytics/tracker hosts to cut page-load
@@ -2894,6 +3283,9 @@ export class BrowserController {
2894
3283
  console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
2895
3284
  }
2896
3285
  async start() {
3286
+ if (this.profileDir.length === 0) {
3287
+ throw new Error("BrowserController.start requires a per-session profile directory");
3288
+ }
2897
3289
  if (this.closePromise !== null)
2898
3290
  throw new Error("BrowserController is already closing");
2899
3291
  this.startPromise ??= this.startOnce();
@@ -2901,24 +3293,19 @@ export class BrowserController {
2901
3293
  }
2902
3294
  async startOnce() {
2903
3295
  const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
2904
- const lease = this.profileOperationLease ??
2905
- (remoteMode ? null : await acquireFreeProfileOperationGuard(this.profileDir));
2906
- this.profileOperationLease = lease;
3296
+ if (!remoteMode)
3297
+ startGlobalOperatorBrowserProcessWatchdog();
2907
3298
  try {
2908
- await this.startWithProfileGuard();
3299
+ await this.startBrowser();
2909
3300
  if (this.startCancellationRequested) {
2910
- await this.closeWithProfileGuard();
3301
+ await this.closeBrowser();
2911
3302
  throw new Error("BrowserController start cancelled");
2912
3303
  }
2913
3304
  }
2914
3305
  catch (err) {
3306
+ await this.teardownOwnedDisplay().catch(() => undefined);
2915
3307
  if (this.startCancellationRequested && this.persistentFallbackCancellationState === null) {
2916
- await this.closeWithProfileGuard().catch(() => undefined);
2917
- }
2918
- if (this.persistentFallbackCancellationState === null ||
2919
- this.persistentFallbackCancellationState === "closed") {
2920
- this.profileOperationLease = null;
2921
- lease?.release();
3308
+ await this.closeBrowser().catch(() => undefined);
2922
3309
  }
2923
3310
  throw err;
2924
3311
  }
@@ -2934,9 +3321,8 @@ export class BrowserController {
2934
3321
  this.throwIfStartCancelled();
2935
3322
  this.startLaunchCommitted = true;
2936
3323
  }
2937
- async startWithProfileGuard() {
3324
+ async startBrowser() {
2938
3325
  this.throwIfStartCancelled();
2939
- reapOrphanedBrowsersOnce(this.profileDir);
2940
3326
  const channel = await detectChromiumChannel();
2941
3327
  this.throwIfStartCancelled();
2942
3328
  this.launchedChannel = channel;
@@ -2951,7 +3337,7 @@ export class BrowserController {
2951
3337
  // (e.g. a Mac with a real GPU + residential egress) and we attach over CDP
2952
3338
  // across Tailscale. The remote machine IS a real device, so we spoof
2953
3339
  // NOTHING — no WebGL/device fingerprint patch (a fake-Intel string over a
2954
- // 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
2955
3341
  // egress-geo override (the remote host's real timezone + residential IP are
2956
3342
  // authentic). software-WebGL output is exactly what the toughest anti-bot
2957
3343
  // (hCaptcha Enterprise) scores; only real hardware fixes the pixel
@@ -2959,15 +3345,20 @@ export class BrowserController {
2959
3345
  const remoteMode = (process.env.BOT_CDP_ENDPOINT ?? "").trim().length > 0;
2960
3346
  if (remoteMode) {
2961
3347
  console.error(`[operator] REMOTE-CDP mode — attaching to ${(process.env.BOT_CDP_ENDPOINT ?? "").trim()} ` +
2962
- `(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;
2963
3353
  }
3354
+ const browserEnv = remoteMode ? process.env : await this.ownedHeadedBrowserEnvironment();
2964
3355
  // T3.1: probe where this run's traffic actually exits so the
2965
3356
  // browser's declared timezone matches its egress IP (a US-timezone
2966
3357
  // browser on a foreign proxy IP is itself an anti-bot signal).
2967
3358
  // Done before the real launch: launchPersistentContext bakes the
2968
3359
  // timezone in at creation, with no way to set it afterward. Skipped in
2969
3360
  // remote mode — the remote host's own clock/IP are the authentic truth.
2970
- const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy);
3361
+ const geo = remoteMode ? null : await this.probeEgressGeo(channel, proxy, browserEnv);
2971
3362
  this.throwIfStartCancelled();
2972
3363
  if (geo !== null) {
2973
3364
  console.error(`[operator] egress geo: timezone=${geo.timezoneId}` +
@@ -2975,84 +3366,12 @@ export class BrowserController {
2975
3366
  ? ` loc=${geo.geolocation.latitude},${geo.geolocation.longitude}`
2976
3367
  : ""));
2977
3368
  }
2978
- // F13 — decide whether to spin up Xvfb and run Chrome headed.
2979
- // Modern SaaS signups (Cloudflare/Stytch, Clerk, Auth0) detect
2980
- // Chromium-headless via JS fingerprints and gate their forms
2981
- // behind the check. Running headed against Xvfb defeats the gate
2982
- // — the user never sees the display.
2983
- //
2984
- // The decision matrix:
2985
- // - UNIVERSAL_BOT_HEADLESS=true (explicit opt-in): keep true
2986
- // headless. CI / Codespaces that lack Xvfb.
2987
- // - UNIVERSAL_BOT_HEADLESS=false (explicit opt-out): the
2988
- // current pre-F13 behavior — DISPLAY must exist already.
2989
- // - default + DISPLAY set: run headed against the existing
2990
- // display (laptop/desktop install).
2991
- // - default + no DISPLAY + Xvfb on PATH: spawn Xvfb, run
2992
- // headed against it (the headless-server install — what
2993
- // Cloudflare needed).
2994
- // - default + no DISPLAY + no Xvfb: fall back to true
2995
- // headless with a clear stderr warning.
2996
- let chromeEnv;
2997
- let chromeHeadless;
2998
- const explicitHeadless = process.env.UNIVERSAL_BOT_HEADLESS;
2999
- const hostHasDisplay = process.platform === "darwin" ||
3000
- process.platform === "win32" ||
3001
- (typeof process.env.DISPLAY === "string" && process.env.DISPLAY.length > 0);
3002
- if (explicitHeadless === "true") {
3003
- chromeHeadless = true;
3004
- this.launchedMode = "headless";
3005
- }
3006
- else if (explicitHeadless === "false") {
3007
- chromeHeadless = false;
3008
- this.launchedMode = "display";
3009
- }
3010
- else if (hostHasDisplay) {
3011
- chromeHeadless = false;
3012
- this.launchedMode = "display";
3013
- }
3014
- else if (xvfbAvailable()) {
3015
- try {
3016
- // 1920×1080 — the most common real desktop resolution. The old
3017
- // 1280×720 here was exactly Playwright's emulated-device viewport
3018
- // default (the code's own comments flag that as an anti-bot tell),
3019
- // and with viewport:null the page read it straight back. A 720p
3020
- // screen whose availHeight==height (no taskbar) is a headless
3021
- // signature strict Turnstiles (exa/cartesia) score against.
3022
- const xvfb = await startXvfb({ width: 1920, height: 1080 });
3023
- if (this.startCancellationRequested) {
3024
- xvfb.stop();
3025
- this.throwIfStartCancelled();
3026
- }
3027
- this.xvfb = xvfb;
3028
- chromeEnv = { ...process.env, DISPLAY: xvfb.display };
3029
- chromeHeadless = false;
3030
- this.launchedMode = "xvfb";
3031
- console.error(`[operator] no DISPLAY — spawned Xvfb at ${this.xvfb.display} for headed Chrome`);
3032
- }
3033
- catch (err) {
3034
- console.error(`[operator] Xvfb failed (${err instanceof Error ? err.message : String(err)}) — ` +
3035
- `falling back to true headless; Cloudflare/Stytch-class signups may fail`);
3036
- chromeHeadless = true;
3037
- this.launchedMode = "headless";
3038
- }
3039
- }
3040
- else {
3041
- console.error(`[operator] no DISPLAY and Xvfb not installed — running true headless. ` +
3042
- `For Cloudflare/Stytch-class signups install xvfb: apt-get install -y xvfb`);
3043
- chromeHeadless = true;
3044
- this.launchedMode = "headless";
3045
- }
3046
- const free = await waitForProfileFree(this.profileDir, { deadlineMs: 0 });
3047
- this.throwIfStartCancelled();
3048
- if (!free) {
3049
- throw new ProfileBusyError(PROFILE_BUSY_MESSAGE);
3050
- }
3051
- // T3: a PERSISTENT context. The profile dir carries the user's
3052
- // Google session (established by `mcp login` — see google-login.ts),
3053
- // so the OAuth-first signup path reuses it instead of starting
3054
- // logged-out. launchPersistentContext takes launch + context
3055
- // 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.
3056
3375
  // Resolve the launcher first so activeStealthProfile is set before we
3057
3376
  // decide on executablePath below.
3058
3377
  const launcher = getChromium();
@@ -3066,7 +3385,7 @@ export class BrowserController {
3066
3385
  this.launchedChannel = channel;
3067
3386
  // Launch args shared by BOTH paths (launchPersistentContext and the
3068
3387
  // self-launch). See the per-flag rationale: swiftshader gives a real
3069
- // (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
3070
3389
  // standard headless/sandbox flags. The three background-throttling disables
3071
3390
  // are payment correctness controls: a backgrounded CardinalCommerce ACS
3072
3391
  // frame must keep running its timers long enough to finish the issuer's OOB
@@ -3101,24 +3420,23 @@ export class BrowserController {
3101
3420
  this.throwIfStartCancelled();
3102
3421
  if (useSelfLaunch && selfLaunchBinary !== null) {
3103
3422
  console.error(`[operator] self-launch + connectOverCDP (Turnstile-safe launch) binary=${selfLaunchBinary}`);
3104
- const window = this.launchedMode === "xvfb"
3105
- ? { width: 1920, height: 1080 }
3106
- : { width: 1280, height: 1024 };
3423
+ const window = { width: 1280, height: 1024 };
3107
3424
  const selfEnv = {
3108
- ...(chromeEnv ?? process.env),
3425
+ ...browserEnv,
3109
3426
  TZ: geo?.timezoneId ?? "America/New_York",
3427
+ [OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
3110
3428
  };
3111
- context = await launchWithProfileGate(this.profileDir, () => {
3429
+ const launch = () => {
3112
3430
  this.throwIfStartCancelled();
3113
3431
  return this.launchSelfManagedContext({
3114
3432
  binary: selfLaunchBinary,
3115
- headless: chromeHeadless,
3116
3433
  args: launchArgs,
3117
3434
  proxy,
3118
3435
  env: selfEnv,
3119
3436
  window,
3120
3437
  });
3121
- }, { failFast: true });
3438
+ };
3439
+ context = await launch();
3122
3440
  try {
3123
3441
  await context.grantPermissions(grantedPermissions);
3124
3442
  if (geo?.geolocation !== undefined) {
@@ -3130,8 +3448,8 @@ export class BrowserController {
3130
3448
  }
3131
3449
  }
3132
3450
  else {
3133
- this.profilePoolReusable = false;
3134
3451
  this.persistentFallbackLaunchInFlight = true;
3452
+ this.startPersistentFallbackOwnershipMonitor();
3135
3453
  const cleanupProfileHolder = async () => {
3136
3454
  const proof = await this.waitForPersistentFallbackIdentity();
3137
3455
  if (proof.state === "absent")
@@ -3139,36 +3457,48 @@ export class BrowserController {
3139
3457
  if (proof.state === "unknown")
3140
3458
  return "unknown";
3141
3459
  const { identity } = proof;
3142
- signalProfileProcess(identity, this.profileDir, "SIGKILL");
3143
- return (await this.waitForOwnedProfileExit(identity)) ? "closed" : "unknown";
3460
+ const treeProof = this.adoptOwnedChromeProcessTree(identity, false);
3461
+ signalOwnedChromeProcessTree(identity, false, "SIGKILL", {
3462
+ ...(treeProof === null ? {} : { proof: treeProof }),
3463
+ });
3464
+ return (await this.waitForOwnedProfileExit(identity, treeProof)) ? "closed" : "unknown";
3144
3465
  };
3145
3466
  const cleanupCancelled = async (lateContext) => {
3146
- const proof = await this.waitForPersistentFallbackIdentity();
3467
+ const proof = await this.waitForPersistentFallbackIdentity().catch(() => ({ state: "unknown" }));
3147
3468
  if (proof.state !== "owned") {
3148
3469
  await lateContext.close().catch(() => undefined);
3149
3470
  return proof.state === "absent" ? "closed" : "unknown";
3150
3471
  }
3151
3472
  const { identity } = proof;
3473
+ const treeProof = this.adoptOwnedChromeProcessTree(identity, false);
3152
3474
  const closeState = await closeProfileWithProof({
3153
3475
  profileDir: this.profileDir,
3154
3476
  identity,
3155
3477
  close: () => lateContext.close(),
3156
3478
  forceClose: () => {
3157
- signalProfileProcess(identity, this.profileDir, "SIGKILL");
3479
+ signalOwnedChromeProcessTree(identity, false, "SIGKILL", {
3480
+ ...(treeProof === null ? {} : { proof: treeProof }),
3481
+ });
3158
3482
  reapProfileHolderIfOwned(this.profileDir, identity);
3159
3483
  },
3484
+ ...(treeProof === null
3485
+ ? {}
3486
+ : { identityState: () => ownedChromeProcessTreeState(treeProof) }),
3160
3487
  });
3161
3488
  if (closeState === "closed")
3162
3489
  return closeState;
3163
- return (await this.waitForOwnedProfileExit(identity)) ? "closed" : "unknown";
3490
+ return (await this.waitForOwnedProfileExit(identity, treeProof)) ? "closed" : "unknown";
3164
3491
  };
3165
3492
  const outcome = await (async () => {
3166
3493
  try {
3167
3494
  return await launchCancellablePersistentContext({
3168
- launch: (options) => launchWithProfileGate(this.profileDir, () => launcher.launchPersistentContext(this.profileDir, options), { failFast: true }),
3495
+ launch: (options) => launcher.launchPersistentContext(this.profileDir, options),
3169
3496
  options: {
3170
- headless: chromeHeadless,
3171
- ...(chromeEnv !== undefined ? { env: chromeEnv } : {}),
3497
+ headless: OPERATOR_BROWSER_HEADLESS,
3498
+ env: {
3499
+ ...browserEnv,
3500
+ [OPERATOR_BROWSER_MARKER_ENV]: this.operatorBrowserMarker(),
3501
+ },
3172
3502
  ...(channel !== null ? { channel } : {}),
3173
3503
  ...persistentProxyOptions(proxy),
3174
3504
  args: [...launchArgs],
@@ -3206,9 +3536,23 @@ export class BrowserController {
3206
3536
  }
3207
3537
  this.context = context;
3208
3538
  this.launchedContext = true;
3209
- const holderPid = currentProfileHolderPid(this.profileDir);
3210
- this.launchedProfileHolderIdentity =
3211
- holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir);
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
+ });
3212
3556
  this.commitProfileLaunch();
3213
3557
  this.persistentFallbackLaunchInFlight = false;
3214
3558
  }
@@ -3220,9 +3564,12 @@ export class BrowserController {
3220
3564
  this.launchedProfileHolderIdentity =
3221
3565
  this.childChromeIdentity ??
3222
3566
  (holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir));
3567
+ if (this.launchedProfileHolderIdentity !== null) {
3568
+ this.adoptOwnedChromeProcessTree(this.launchedProfileHolderIdentity, this.childChromeIdentity !== null && this.childChromeProcessGroup);
3569
+ }
3223
3570
  }
3224
3571
  if (this.startCancellationRequested) {
3225
- await this.closeWithProfileGuard();
3572
+ await this.closeBrowser();
3226
3573
  throw new Error("BrowserController start cancelled");
3227
3574
  }
3228
3575
  // Speed: optionally abort heavy/irrelevant requests before any navigation.
@@ -3325,7 +3672,7 @@ export class BrowserController {
3325
3672
  get: () => 8,
3326
3673
  configurable: true,
3327
3674
  });
3328
- // Screen availHeight tell: a headless Xvfb screen reports
3675
+ // Screen availHeight tell: a virtual screen reports
3329
3676
  // availHeight == height (no OS taskbar), whereas a real Windows
3330
3677
  // desktop reserves ~40px for the taskbar (availHeight = height-40,
3331
3678
  // availWidth = width). Reinstate that gap so the screen reads like
@@ -3356,7 +3703,11 @@ export class BrowserController {
3356
3703
  if (contextInitScripts.includes("webgl-spoof")) {
3357
3704
  await context.addInitScript({ content: installWebglSpoofScript });
3358
3705
  }
3706
+ for (const page of context.pages())
3707
+ this.trackMainDocument(page);
3708
+ this.trackOpenedTabs(context);
3359
3709
  this.page = context.pages()[0] ?? (await context.newPage());
3710
+ this.trackMainDocument(this.page);
3360
3711
  this.primaryPage = this.page;
3361
3712
  // In baseline mode addInitScript covers document-start page JS, but
3362
3713
  // Playwright's page.evaluate utility execution can run in a separate realm.
@@ -3483,7 +3834,7 @@ export class BrowserController {
3483
3834
  // timezone has to be known before it is. The throwaway inherits the
3484
3835
  // same channel + proxy so it reports the real egress. Best-effort —
3485
3836
  // any failure returns null and start() keeps a default timezone.
3486
- async probeEgressGeo(channel, proxy) {
3837
+ async probeEgressGeo(channel, proxy, browserEnv) {
3487
3838
  if (proxy === null) {
3488
3839
  try {
3489
3840
  const resp = await fetch("https://ipinfo.io/json", { signal: AbortSignal.timeout(10_000) });
@@ -3500,7 +3851,8 @@ export class BrowserController {
3500
3851
  let probe;
3501
3852
  try {
3502
3853
  probe = await getChromium().launch({
3503
- headless: process.env.UNIVERSAL_BOT_HEADLESS !== "false",
3854
+ headless: OPERATOR_BROWSER_HEADLESS,
3855
+ env: browserEnv,
3504
3856
  ...(channel !== null ? { channel } : {}),
3505
3857
  ...(proxy !== null ? { proxy } : {}),
3506
3858
  args: ["--no-sandbox", "--disable-dev-shm-usage"],
@@ -3791,7 +4143,28 @@ export class BrowserController {
3791
4143
  async type(selector, text, sealed = false) {
3792
4144
  if (!this.page)
3793
4145
  throw new Error("Browser not started");
3794
- 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);
3795
4168
  }
3796
4169
  async typeInner(selector, text, sealed = false) {
3797
4170
  if (!this.page)
@@ -3799,16 +4172,16 @@ export class BrowserController {
3799
4172
  // Wait for element to be visible and enabled before typing.
3800
4173
  await this.page.waitForSelector(selector, { state: "visible", timeout: 10000 });
3801
4174
  const locator = this.page.locator(selector);
3802
- const sealedFieldKeys = sealed
3803
- ? await this.operatorScreenshotIdentityKeys(locator, this.page.mainFrame())
3804
- : [];
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.
3805
4178
  if (sealed) {
3806
4179
  await locator.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
3807
4180
  }
3808
4181
  if (!this.humanize) {
3809
4182
  // Fast path for tests / non-humanized runs.
3810
4183
  await this.page.fill(selector, text);
3811
- return sealedFieldKeys;
4184
+ return;
3812
4185
  }
3813
4186
  // Humanized typing:
3814
4187
  // - Click into the field first (moves mouse, generates focus event)
@@ -3839,7 +4212,6 @@ export class BrowserController {
3839
4212
  // hook, and over-engineering it added zero observable behavior-
3840
4213
  // score improvement.
3841
4214
  await locator.pressSequentially(text, { delay: rand(40, 110) });
3842
- return sealedFieldKeys;
3843
4215
  }
3844
4216
  // Best-effort scan for the SPECIFIC unfilled required field(s) blocking a
3845
4217
  // disabled submit. Returns a " Unfilled required field(s) — …" suffix for the
@@ -4827,20 +5199,16 @@ export class BrowserController {
4827
5199
  const ownerFrame = await handle.ownerFrame();
4828
5200
  if (ownerFrame === null)
4829
5201
  throw new Error("locator target has no owning frame");
4830
- const sealedFieldKeys = sealed
4831
- ? await this.operatorScreenshotIdentityKeys(handle, ownerFrame)
4832
- : [];
4833
5202
  if (sealed) {
4834
5203
  await handle.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
4835
5204
  }
4836
5205
  if (!this.humanize) {
4837
5206
  await handle.fill(text);
4838
- return sealedFieldKeys;
5207
+ return;
4839
5208
  }
4840
5209
  await handle.click({ timeout: 8000 }).catch(() => undefined);
4841
5210
  await handle.fill("").catch(() => undefined);
4842
5211
  await handle.type(text, { delay: rand(40, 110) });
4843
- return sealedFieldKeys;
4844
5212
  }
4845
5213
  // Dispatch a DOM .click() in the page context. Some React copy buttons fire
4846
5214
  // their onClick (and thus navigator.clipboard.writeText) on the synthetic
@@ -6439,7 +6807,7 @@ export class BrowserController {
6439
6807
  // device memory, screen, languages, webdriver flag. Turnstile
6440
6808
  // error 600010 ("internal client execution error") usually points
6441
6809
  // at one of these returning something the challenge JS can't
6442
- // handle (e.g. SwiftShader/llvmpipe renderer under Xvfb).
6810
+ // handle (e.g. a SwiftShader/llvmpipe renderer).
6443
6811
  if (process.env.UNIVERSAL_BOT_CAPTCHA_TRACE === "1") {
6444
6812
  try {
6445
6813
  const fp = await this.page.evaluate(() => {
@@ -7320,10 +7688,6 @@ export class BrowserController {
7320
7688
  hasHcaptchaGlobal: win.hcaptcha !== undefined,
7321
7689
  };
7322
7690
  }, { tok: token, key: responseKey });
7323
- if (process.env.UNIVERSAL_BOT_OAUTH_DEBUG) {
7324
- console.error(`[captcha] hCaptcha inject diag: ok=${diag.ok} textareas=${diag.textareas} ` +
7325
- `widgets=${diag.widgets} callbackFired=${diag.callbackFired} hcaptchaGlobal=${diag.hasHcaptchaGlobal}`);
7326
- }
7327
7691
  return diag.ok;
7328
7692
  }
7329
7693
  catch {
@@ -7542,582 +7906,78 @@ export class BrowserController {
7542
7906
  }
7543
7907
  return null;
7544
7908
  }
7545
- async operatorScreenshotCaptureScope(targetFrame) {
7546
- if (!this.page)
7547
- throw new Error("Browser not started");
7548
- if (targetFrame === null || targetFrame === this.page.mainFrame()) {
7549
- const frames = this.page.frames();
7550
- return { frames, strictFrames: new Set(frames), clip: null };
7551
- }
7552
- const strictFrames = new Set();
7553
- const visit = (frame) => {
7554
- strictFrames.add(frame);
7555
- for (const child of frame.childFrames())
7556
- visit(child);
7557
- };
7558
- visit(targetFrame);
7559
- const targetHandle = await targetFrame.frameElement();
7560
- const clip = await targetHandle.boundingBox();
7561
- await targetHandle.dispose().catch(() => undefined);
7562
- if (clip === null)
7563
- throw new Error("screenshot_redaction_unresolved");
7564
- const intersects = (box) => box.x < clip.x + clip.width &&
7565
- box.x + box.width > clip.x &&
7566
- box.y < clip.y + clip.height &&
7567
- box.y + box.height > clip.y;
7568
- const frames = [];
7569
- for (const frame of this.page.frames()) {
7570
- if (frame === this.page.mainFrame() || strictFrames.has(frame)) {
7571
- frames.push(frame);
7572
- continue;
7573
- }
7574
- const handle = await frame.frameElement();
7575
- try {
7576
- const box = await handle.boundingBox();
7577
- if (box !== null && intersects(box))
7578
- frames.push(frame);
7579
- }
7580
- finally {
7581
- await handle.dispose().catch(() => undefined);
7582
- }
7583
- }
7584
- return { frames, strictFrames, clip };
7585
- }
7586
- async sealedDocumentIdentity(frame) {
7587
- const current = this.sealedDocuments.get(frame);
7588
- if (current !== undefined &&
7589
- !frame.isDetached() &&
7590
- (await frame.evaluate((expected) => document === expected, current.handle).catch(() => false))) {
7591
- return current.identity;
7592
- }
7593
- await current?.handle.dispose().catch(() => undefined);
7594
- const handle = await frame.evaluateHandle(() => document);
7595
- const identity = `document-${this.sealedDocumentSequence++}`;
7596
- this.sealedDocuments.set(frame, { handle, identity });
7597
- return identity;
7598
- }
7599
- async operatorScreenshotIdentityKeys(target, frame) {
7600
- const descriptor = await target.evaluate((el) => {
7601
- const clean = (value) => {
7602
- const normalized = (value ?? "").replace(/\s+/g, " ").trim();
7603
- return normalized.length > 0 ? normalized : null;
7604
- };
7605
- const labelFor = (element) => {
7606
- const id = element.getAttribute("id");
7607
- if (id !== null && id.length > 0) {
7608
- const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
7609
- if (label !== null)
7610
- return clean(label.textContent);
7611
- }
7612
- const labelledBy = element.getAttribute("aria-labelledby");
7613
- if (labelledBy !== null) {
7614
- const text = labelledBy
7615
- .split(/\s+/)
7616
- .map((part) => clean(document.getElementById(part)?.textContent))
7617
- .filter((part) => part !== null)
7618
- .join(" ");
7619
- if (text.length > 0)
7620
- return text;
7621
- }
7622
- return clean(element.closest("label")?.textContent);
7623
- };
7624
- const controls = Array.from(el.getRootNode().querySelectorAll("input,textarea,select,[contenteditable='true']"));
7625
- return {
7626
- tag: el.tagName.toLowerCase(),
7627
- type: el.getAttribute("type"),
7628
- id: el.getAttribute("id"),
7629
- name: el.getAttribute("name"),
7630
- testId: el.getAttribute("data-testid") ??
7631
- el.getAttribute("data-test-id") ??
7632
- el.getAttribute("data-test") ??
7633
- el.getAttribute("data-cy") ??
7634
- el.getAttribute("data-qa"),
7635
- labelText: labelFor(el),
7636
- ariaLabel: el.getAttribute("aria-label"),
7637
- placeholder: el.getAttribute("placeholder"),
7638
- landmark: el.closest("header,main,footer,nav,aside,article,section")?.tagName.toLowerCase() ?? null,
7639
- ordinal: controls.indexOf(el),
7640
- };
7641
- });
7642
- const documentIdentity = await this.sealedDocumentIdentity(frame);
7643
- return sealedElementSemanticKeys(descriptor).map((key) => `${documentIdentity}:${key}`);
7644
- }
7645
- async resolveOperatorScreenshotSealedLocators(frames, sealedFieldKeys) {
7646
- const byFrame = new Map();
7647
- for (const frame of frames) {
7648
- const matches = [];
7649
- const candidates = await frame
7650
- .locator("input,textarea,select,[contenteditable='true']")
7651
- .all();
7652
- for (const candidate of candidates) {
7653
- const keys = await this.operatorScreenshotIdentityKeys(candidate, frame);
7654
- if (keys.some((key) => sealedFieldKeys.has(key)))
7655
- matches.push(candidate);
7656
- }
7657
- byFrame.set(frame, matches);
7658
- }
7659
- return byFrame;
7660
- }
7661
- // Resolve every card-shaped/sealed field in the captured frames to a
7662
- // screenshot redaction rectangles — text-masking (presentFieldValue in
7663
- // provision-session.ts) only covers the JSON observation, not a rendered
7664
- // image. Beyond the attribute-based
7665
- // selector set, every renderable input/textarea whose CURRENT value contains
7666
- // a Luhn-valid PAN span is masked too (containsLuhnPanSpan — the same
7667
- // detection the payment paths use), so a card number sitting in a field the
7668
- // fixed selectors don't recognize still never reaches the image. Fail-closed
7669
- // throughout: a selector that cannot be queried, a value that cannot be
7670
- // read, or a matched element whose geometry cannot be resolved (boundingBox
7671
- // returns null rather than throwing) aborts the whole capture rather than
7672
- // shrinking the redaction. The per-frame count signature lets the caller
7673
- // verify the redaction set stayed stable across the capture window.
7674
- async collectOperatorScreenshotMask(frames, extraRedactionSelectors, sealedLocators = new Map(), captureClip = null, strictFrames = new Set(frames)) {
7675
- const selector = [SCREENSHOT_REDACTION_SELECTORS, ...extraRedactionSelectors].join(",");
7676
- const rectangles = [];
7677
- const handles = [];
7678
- const signatureParts = [];
7679
- try {
7680
- for (const [frameIndex, frame] of frames.entries()) {
7681
- let frameCount = 0;
7682
- const frameHandles = await frame.locator(selector).elementHandles();
7683
- for (const locator of sealedLocators.get(frame) ?? []) {
7684
- const handle = await locator.elementHandle({ timeout: 5_000 });
7685
- if (handle === null)
7686
- throw new Error("screenshot_redaction_unresolved");
7687
- if (!(await handle.evaluate((el, matchSelector) => el.matches(matchSelector), selector))) {
7688
- frameHandles.push(handle);
7689
- }
7690
- else {
7691
- await handle.dispose();
7692
- }
7693
- }
7694
- const valueCandidates = await frame
7695
- .locator('input:not([type="hidden" i]),textarea')
7696
- .elementHandles();
7697
- for (const candidate of valueCandidates) {
7698
- const value = await candidate.inputValue({ timeout: 5_000 });
7699
- if (!containsLuhnPanSpan(value)) {
7700
- await candidate.dispose();
7701
- continue;
7702
- }
7703
- const duplicate = await Promise.all(frameHandles.map(async (existing) => await candidate.evaluate((el, other) => el === other, existing)));
7704
- if (duplicate.includes(true)) {
7705
- await candidate.dispose();
7706
- continue;
7707
- }
7708
- frameHandles.push(candidate);
7709
- }
7710
- for (const handle of frameHandles) {
7711
- if (!(await handle.evaluate((el) => el.isConnected))) {
7712
- throw new Error("screenshot_redaction_unresolved");
7713
- }
7714
- const box = await handle.boundingBox();
7715
- if (captureClip !== null &&
7716
- !strictFrames.has(frame) &&
7717
- (box === null ||
7718
- box.x >= captureClip.x + captureClip.width ||
7719
- box.x + box.width <= captureClip.x ||
7720
- box.y >= captureClip.y + captureClip.height ||
7721
- box.y + box.height <= captureClip.y)) {
7722
- await handle.dispose();
7723
- continue;
7724
- }
7725
- handles.push(handle);
7726
- if (box !== null)
7727
- rectangles.push(box);
7728
- signatureParts.push(`${frameIndex}:${box === null ? "hidden" : [box.x, box.y, box.width, box.height].join(":")}`);
7729
- frameCount += 1;
7730
- }
7731
- signatureParts.push(`count:${frameIndex}:${frameCount}`);
7732
- }
7733
- }
7734
- catch {
7735
- await Promise.all(handles.map(async (handle) => await handle.dispose().catch(() => undefined)));
7736
- throw new Error("screenshot_redaction_unresolved");
7737
- }
7738
- return {
7739
- rectangles,
7740
- redactedCount: handles.length,
7741
- signature: signatureParts.join("|"),
7742
- handles,
7743
- };
7744
- }
7745
- async redactOperatorScreenshot(buffer, rectangles, origin, captureSize) {
7746
- const metadata = await sharp(buffer).metadata();
7747
- if (metadata.width === undefined || metadata.height === undefined) {
7748
- throw new Error("screenshot_redaction_unresolved");
7749
- }
7750
- const scaleX = metadata.width / captureSize.width;
7751
- const scaleY = metadata.height / captureSize.height;
7752
- const rects = rectangles
7753
- .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"/>`)
7754
- .join("");
7755
- const overlay = Buffer.from(`<svg width="${metadata.width}" height="${metadata.height}" xmlns="http://www.w3.org/2000/svg">${rects}</svg>`);
7756
- return (await sharp(buffer)
7757
- .composite([{ input: overlay, blend: "over" }])
7758
- .jpeg({ quality: 80 })
7759
- .toBuffer()).toString("base64");
7760
- }
7761
- // Verify the capture set before pixels are read. This is deliberately
7762
- // narrower than the capture-time mask: empty checkout controls are harmless,
7763
- // but a nonempty type_secret target, password, payment-sealed node, or
7764
- // Luhn-valid PAN means the requested image could contain a secret. Every
7765
- // frame included by the image must be readable; a detached or navigating
7766
- // frame is not evidence that it is safe to capture.
7767
- async assertOperatorScreenshotFramesNoSealedValues(frames, extraRedactionSelectors, sealedLocators = new Map(), captureClip = null, strictFrames = new Set(frames)) {
7768
- const sealedSelector = [SCREENSHOT_REDACTION_SELECTORS, ...extraRedactionSelectors].join(",");
7769
- try {
7770
- for (const frame of frames) {
7771
- if (frame.isDetached())
7772
- throw new Error("frame detached");
7773
- const sealedMatches = [
7774
- ...(await frame.locator(sealedSelector).all()),
7775
- ...(sealedLocators.get(frame) ?? []),
7776
- ];
7777
- for (const match of sealedMatches) {
7778
- const box = strictFrames.has(frame) ? null : await match.boundingBox();
7779
- if (captureClip !== null &&
7780
- !strictFrames.has(frame) &&
7781
- (box === null ||
7782
- box.x >= captureClip.x + captureClip.width ||
7783
- box.x + box.width <= captureClip.x ||
7784
- box.y >= captureClip.y + captureClip.height ||
7785
- box.y + box.height <= captureClip.y)) {
7786
- continue;
7787
- }
7788
- const hasValue = await match.evaluate((el) => {
7789
- if (el instanceof HTMLSelectElement) {
7790
- const optionText = el.options[el.selectedIndex]?.textContent ?? "";
7791
- const normalizedOption = optionText.replace(/\s+/g, " ").trim();
7792
- const placeholder = /^(?:select|choose)?\s*(?:a\s+)?(?:month|year|mm|yy)?\s*(?:\.\.\.|[-–—]*)$/i;
7793
- return (el.value.trim().length > 0 ||
7794
- (normalizedOption.length > 0 && !placeholder.test(normalizedOption)));
7795
- }
7796
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
7797
- return el.value.trim().length > 0;
7798
- }
7799
- return (el.textContent ?? "").trim().length > 0;
7800
- });
7801
- if (hasValue)
7802
- throw new Error("sealed value");
7803
- }
7804
- const candidates = await frame.locator('input:not([type="hidden" i]),textarea').all();
7805
- for (const candidate of candidates) {
7806
- const box = strictFrames.has(frame) ? null : await candidate.boundingBox();
7807
- if (captureClip !== null &&
7808
- !strictFrames.has(frame) &&
7809
- (box === null ||
7810
- box.x >= captureClip.x + captureClip.width ||
7811
- box.x + box.width <= captureClip.x ||
7812
- box.y >= captureClip.y + captureClip.height ||
7813
- box.y + box.height <= captureClip.y)) {
7814
- continue;
7815
- }
7816
- const hasPan = await candidate.evaluate((el) => {
7817
- const text = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : "";
7818
- const digits = Array.from(text.matchAll(/\d/g), (match) => match.index);
7819
- const luhn = (value) => {
7820
- let sum = 0;
7821
- let double = false;
7822
- for (let index = value.length - 1; index >= 0; index -= 1) {
7823
- let digit = Number(value[index]);
7824
- if (double) {
7825
- digit *= 2;
7826
- if (digit > 9)
7827
- digit -= 9;
7828
- }
7829
- sum += digit;
7830
- double = !double;
7831
- }
7832
- return sum % 10 === 0;
7833
- };
7834
- for (let start = 0; start + 13 <= digits.length; start += 1) {
7835
- const maxLength = Math.min(19, digits.length - start);
7836
- for (let length = 13; length <= maxLength; length += 1) {
7837
- const positions = digits.slice(start, start + length);
7838
- if (positions[positions.length - 1] - positions[0] + 1 >
7839
- PAYMENT_PAN_MAX_SPAN_CHARS) {
7840
- break;
7841
- }
7842
- if (luhn(positions.map((position) => text[position]).join("")))
7843
- return true;
7844
- }
7845
- }
7846
- return false;
7847
- });
7848
- if (hasPan)
7849
- throw new Error("card value");
7850
- }
7851
- if (strictFrames.has(frame) || captureClip === null || frame !== this.page?.mainFrame()) {
7852
- const renderedText = await frame.evaluate(() => document.body?.innerText ?? document.documentElement?.innerText ?? "");
7853
- if (containsLuhnPanSpan(renderedText))
7854
- throw new Error("rendered card value");
7855
- }
7856
- else {
7857
- const renderedTextInClip = await frame.evaluate((clip) => {
7858
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
7859
- const texts = [];
7860
- let node = walker.nextNode();
7861
- while (node !== null) {
7862
- const text = node.textContent ?? "";
7863
- if (/\d/.test(text)) {
7864
- const range = document.createRange();
7865
- range.selectNodeContents(node);
7866
- const overlaps = Array.from(range.getClientRects()).some((rect) => rect.left < clip.x + clip.width &&
7867
- rect.right > clip.x &&
7868
- rect.top < clip.y + clip.height &&
7869
- rect.bottom > clip.y);
7870
- if (overlaps)
7871
- texts.push(text);
7872
- }
7873
- node = walker.nextNode();
7874
- }
7875
- return texts.join(" ");
7876
- }, captureClip);
7877
- if (containsLuhnPanSpan(renderedTextInClip))
7878
- throw new Error("rendered card value");
7879
- }
7880
- }
7881
- }
7882
- catch {
7883
- throw new Error("screenshot_unavailable_sealed_context");
7884
- }
7885
- }
7886
- async captureOperatorScreenshot(opts = {}, sealedFieldKeys = []) {
7887
- if (!this.page)
7888
- throw new Error("Browser not started");
7889
- const targetFrame = this.resolveOperatorScreenshotFrame(opts);
7890
- const scope = await this.operatorScreenshotCaptureScope(targetFrame);
7891
- const { frames } = scope;
7892
- const documents = [];
7893
- try {
7894
- for (const frame of frames) {
7895
- if (frame.isDetached())
7896
- throw new Error("frame detached");
7897
- documents.push(await frame.evaluateHandle(() => document));
7898
- }
7899
- const sealedLocators = await this.resolveOperatorScreenshotSealedLocators(frames, new Set(sealedFieldKeys));
7900
- await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, scope.clip, scope.strictFrames);
7901
- const documentsStillCurrent = async () => {
7902
- if (documents.length !== frames.length)
7903
- return false;
7904
- for (let index = 0; index < frames.length; index += 1) {
7905
- const frame = frames[index];
7906
- if (frame.isDetached())
7907
- return false;
7908
- if (!(await frame.evaluate((expected) => document === expected, documents[index]))) {
7909
- return false;
7910
- }
7911
- }
7912
- return true;
7913
- };
7914
- if (!(await documentsStillCurrent())) {
7915
- throw new Error("screenshot_unavailable_sealed_context");
7916
- }
7917
- let captured;
7918
- let attemptScope = scope;
7919
- for (let attempt = 0; attempt < 2; attempt += 1) {
7920
- if (attempt > 0) {
7921
- attemptScope = await this.operatorScreenshotCaptureScope(targetFrame);
7922
- if (attemptScope.frames.length !== frames.length ||
7923
- !frames.every((frame, index) => attemptScope.frames[index] === frame)) {
7924
- throw new Error("screenshot_unavailable_sealed_context");
7925
- }
7926
- }
7927
- try {
7928
- captured = await this.screenshotForOperatorResolved(opts, targetFrame, frames, sealedLocators, attemptScope, async () => {
7929
- if (!(await documentsStillCurrent())) {
7930
- throw new Error("screenshot_unavailable_sealed_context");
7931
- }
7932
- await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, attemptScope.clip, attemptScope.strictFrames);
7933
- if (!(await documentsStillCurrent())) {
7934
- throw new Error("screenshot_unavailable_sealed_context");
7935
- }
7936
- });
7937
- break;
7938
- }
7939
- catch (error) {
7940
- if (attempt === 0 &&
7941
- error instanceof Error &&
7942
- error.message === "screenshot_redaction_unstable") {
7943
- continue;
7944
- }
7945
- throw error;
7946
- }
7947
- }
7948
- if (captured === undefined)
7949
- throw new Error("screenshot_unavailable_sealed_context");
7950
- if (!(await documentsStillCurrent())) {
7951
- throw new Error("screenshot_unavailable_sealed_context");
7952
- }
7953
- await this.assertOperatorScreenshotFramesNoSealedValues(frames, [], sealedLocators, attemptScope.clip, attemptScope.strictFrames);
7954
- return captured;
7955
- }
7956
- catch (error) {
7957
- if (error instanceof Error && error.message === "screenshot_frame_not_found")
7958
- throw error;
7959
- if (error instanceof Error && error.message === "screenshot_unavailable_sealed_context") {
7960
- throw error;
7961
- }
7962
- throw new Error("screenshot_unavailable_sealed_context");
7963
- }
7964
- finally {
7965
- await Promise.all(documents.map(async (document) => await document.dispose().catch(() => undefined)));
7966
- }
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);
7967
7914
  }
7968
- // Low-level read-only capture used by captureOperatorScreenshot after its
7969
- // capture-scoped sealed-value checks, and directly by redaction tests. It
7970
- // never navigates, clicks, types, focuses, or mutates the DOM. Redaction
7971
- // covers every frame included in the image: the whole page composites its
7972
- // visible frames, while an isolated frame includes only that frame.
7973
- // `extraRedactionSelectors` is retained for callers that already resolved
7974
- // additional sensitive elements; production capture passes durable locators.
7975
7915
  async screenshotForOperator(opts = {}) {
7976
- if (!this.page)
7977
- throw new Error("Browser not started");
7978
- const targetFrame = this.resolveOperatorScreenshotFrame(opts);
7979
- const scope = await this.operatorScreenshotCaptureScope(targetFrame);
7980
- return await this.screenshotForOperatorResolved(opts, targetFrame, scope.frames, new Map(), scope);
7981
- }
7982
- async screenshotForOperatorResolved(opts, targetFrame, framesBefore, sealedLocators = new Map(), scope = { frames: [...framesBefore], strictFrames: new Set(framesBefore), clip: null }, beforeCapture) {
7983
7916
  if (!this.page)
7984
7917
  throw new Error("Browser not started");
7985
7918
  const page = this.page;
7986
- const extraRedactionSelectors = opts.extraRedactionSelectors ?? [];
7987
- const before = await this.collectOperatorScreenshotMask(framesBefore, extraRedactionSelectors, sealedLocators, scope.clip, scope.strictFrames);
7988
- const { rectangles, redactedCount, signature } = before;
7989
- let recheck;
7919
+ const targetFrame = this.resolveOperatorScreenshotFrame(opts);
7920
+ const cdp = await page.context().newCDPSession(page);
7990
7921
  try {
7991
- // caret:"initial" skips Playwright's default caret-hiding pass, which
7992
- // writes (and restores) caret-color on every editable element's inline
7993
- // style — this capture must leave element styles untouched.
7994
- let buffer;
7995
- let origin = { x: 0, y: 0 };
7996
- let captureSize;
7997
- const cdp = await page.context().newCDPSession(page);
7998
- try {
7999
- if (targetFrame !== null && targetFrame !== page.mainFrame()) {
8000
- const handle = await targetFrame.frameElement();
8001
- try {
8002
- const box = await handle.boundingBox();
8003
- if (box === null)
8004
- throw new Error("screenshot_redaction_unresolved");
8005
- if (scope.clip === null ||
8006
- box.x !== scope.clip.x ||
8007
- box.y !== scope.clip.y ||
8008
- box.width !== scope.clip.width ||
8009
- box.height !== scope.clip.height) {
8010
- throw new Error("screenshot_redaction_unresolved");
8011
- }
8012
- const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
8013
- origin = { x: box.x, y: box.y };
8014
- captureSize = { width: box.width, height: box.height };
8015
- await beforeCapture?.();
8016
- const result = await cdp.send("Page.captureScreenshot", {
8017
- format: "jpeg",
8018
- quality: 80,
8019
- fromSurface: true,
8020
- captureBeyondViewport: true,
8021
- clip: {
8022
- x: box.x + scroll.x,
8023
- y: box.y + scroll.y,
8024
- width: box.width,
8025
- height: box.height,
8026
- scale: 1,
8027
- },
8028
- });
8029
- buffer = Buffer.from(result.data, "base64");
8030
- }
8031
- finally {
8032
- await handle.dispose().catch(() => undefined);
8033
- }
8034
- }
8035
- else if (opts.fullPage === true) {
8036
- const dimensions = await page.evaluate(() => ({
8037
- origin: { x: -window.scrollX, y: -window.scrollY },
8038
- size: {
8039
- width: document.documentElement.scrollWidth,
8040
- height: document.documentElement.scrollHeight,
8041
- },
8042
- }));
8043
- origin = dimensions.origin;
8044
- captureSize = dimensions.size;
8045
- await beforeCapture?.();
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();
7927
+ try {
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 }));
8046
7932
  const result = await cdp.send("Page.captureScreenshot", {
8047
7933
  format: "jpeg",
8048
7934
  quality: 80,
8049
7935
  fromSurface: true,
8050
7936
  captureBeyondViewport: true,
8051
7937
  clip: {
8052
- x: 0,
8053
- y: 0,
8054
- width: dimensions.size.width,
8055
- height: dimensions.size.height,
7938
+ x: box.x + scroll.x,
7939
+ y: box.y + scroll.y,
7940
+ width: box.width,
7941
+ height: box.height,
8056
7942
  scale: 1,
8057
7943
  },
8058
7944
  });
8059
- buffer = Buffer.from(result.data, "base64");
7945
+ base64 = result.data;
8060
7946
  }
8061
- else {
8062
- const viewport = page.viewportSize();
8063
- if (viewport === null)
8064
- throw new Error("screenshot_redaction_unresolved");
8065
- captureSize = viewport;
8066
- await beforeCapture?.();
8067
- const result = await cdp.send("Page.captureScreenshot", {
8068
- format: "jpeg",
8069
- quality: 80,
8070
- fromSurface: true,
8071
- });
8072
- buffer = Buffer.from(result.data, "base64");
7947
+ finally {
7948
+ await handle.dispose().catch(() => undefined);
8073
7949
  }
8074
7950
  }
8075
- finally {
8076
- await cdp.detach().catch(() => undefined);
8077
- }
8078
- // Stability guard: the mask set was fixed before a capture that can take
8079
- // seconds. If the page grew another matching field or frame meanwhile,
8080
- // the image may hold pixels no mask covered — re-run the same collection
8081
- // and discard the image unless the frame set and per-frame redaction
8082
- // signature are unchanged.
8083
- const scopeAfter = await this.operatorScreenshotCaptureScope(targetFrame);
8084
- const framesAfter = scopeAfter.frames;
8085
- let stable = JSON.stringify(scopeAfter.clip) === JSON.stringify(scope.clip) &&
8086
- framesAfter.length === framesBefore.length &&
8087
- framesBefore.every((frame, index) => framesAfter[index] === frame);
8088
- if (stable) {
8089
- try {
8090
- recheck = await this.collectOperatorScreenshotMask(framesAfter, extraRedactionSelectors, sealedLocators, scope.clip, scope.strictFrames);
8091
- stable =
8092
- recheck.signature === signature && recheck.handles.length === before.handles.length;
8093
- if (stable) {
8094
- for (let index = 0; index < before.handles.length; index += 1) {
8095
- if (!(await recheck.handles[index].evaluate((el, expected) => el === expected, before.handles[index]))) {
8096
- stable = false;
8097
- break;
8098
- }
8099
- }
8100
- }
8101
- }
8102
- catch {
8103
- stable = false;
8104
- }
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;
8105
7972
  }
8106
- if (!stable)
8107
- throw new Error("screenshot_redaction_unstable");
8108
- const base64 = await this.redactOperatorScreenshot(buffer, rectangles, origin, captureSize);
8109
7973
  return {
8110
7974
  base64,
8111
7975
  frameUrl: targetFrame?.url() ?? null,
8112
7976
  frameCount: page.frames().length,
8113
- redactedCount,
8114
7977
  };
8115
7978
  }
8116
7979
  finally {
8117
- await Promise.all([
8118
- ...before.handles.map(async (handle) => await handle.dispose().catch(() => undefined)),
8119
- ...(recheck?.handles ?? []).map(async (handle) => await handle.dispose().catch(() => undefined)),
8120
- ]);
7980
+ await cdp.detach().catch(() => undefined);
8121
7981
  }
8122
7982
  }
8123
7983
  async getState() {
@@ -8167,6 +8027,32 @@ export class BrowserController {
8167
8027
  throw new Error("Browser not started");
8168
8028
  return await this.page.evaluate(extractObservationVisibleText);
8169
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
+ }
8170
8056
  async readCheckoutSummary(fallbackCurrency) {
8171
8057
  if (!this.page)
8172
8058
  throw new Error("Browser not started");
@@ -8461,6 +8347,28 @@ export class BrowserController {
8461
8347
  });
8462
8348
  return includeDetails ? items : items.map(({ title, quantity }) => ({ title, quantity }));
8463
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
+ }
8464
8372
  async readSettledCheckoutReviewSummary(fallbackCurrency, timeoutMs = 12_000) {
8465
8373
  if (!this.page)
8466
8374
  throw new Error("Browser not started");
@@ -8521,10 +8429,10 @@ export class BrowserController {
8521
8429
  }
8522
8430
  }
8523
8431
  // The first frame that actually renders a visible card (PAN) input, or null.
8524
- async panFieldFrame() {
8432
+ async panFieldFrame(frames) {
8525
8433
  if (!this.page)
8526
8434
  return null;
8527
- for (const frame of this.page.frames()) {
8435
+ for (const frame of frames ?? this.page.frames()) {
8528
8436
  const locator = frame.locator(CHECKOUT_PAN_FIELD_SELECTORS);
8529
8437
  const count = await locator.count().catch(() => 0);
8530
8438
  for (let i = 0; i < count; i += 1) {
@@ -8537,7 +8445,7 @@ export class BrowserController {
8537
8445
  }
8538
8446
  return null;
8539
8447
  }
8540
- // 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
8541
8449
  // single-page checkout's card entry can live in a cross-origin PCI iframe
8542
8450
  // (e.g. Shopify's checkout.pci.shopifyinc.com) that mounts only after the
8543
8451
  // payment section itself renders — later than the total becomes readable,
@@ -8545,7 +8453,7 @@ export class BrowserController {
8545
8453
  // fillAndSubmitCheckout/fillCheckoutCardFields used to take one frames()
8546
8454
  // snapshot at call time; a frame that hadn't mounted YET at that exact
8547
8455
  // instant made a genuinely fillable checkout fail closed.
8548
- async waitForPanField(timeoutMs) {
8456
+ async waitForPanField(timeoutMs, frameAllowed = () => true) {
8549
8457
  if (!this.page)
8550
8458
  return;
8551
8459
  const deadline = Date.now() + timeoutMs;
@@ -8555,14 +8463,108 @@ export class BrowserController {
8555
8463
  // CHECKOUT_PAN_FIELD_SELECTORS once stamped — without this,
8556
8464
  // panFieldFrame() below can never match and every call here burns its
8557
8465
  // full timeoutMs even though the field was on the page from the start.
8558
- await this.stampJapaneseCardLabelFields(this.page.frames());
8559
- 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)
8560
8469
  return;
8561
8470
  if (Date.now() >= deadline)
8562
8471
  return;
8563
8472
  await this.page.waitForTimeout(200).catch(() => undefined);
8564
8473
  }
8565
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
+ }
8566
8568
  // Normalizes conservative PAN name/id conventions and scans
8567
8569
  // dt/th/label/"table-label" elements for カード番号 / カード名義 /
8568
8570
  // セキュリティコード and stamps the associated single text input with
@@ -9610,7 +9612,7 @@ export class BrowserController {
9610
9612
  ? undefined
9611
9613
  : { selected: cardGroup, groups: [...groups.values()] };
9612
9614
  }
9613
- async fillAndSubmitCheckout(card) {
9615
+ async fillAndSubmitCheckout(card, options = {}) {
9614
9616
  if (!this.page)
9615
9617
  throw new Error("Browser not started");
9616
9618
  this.checkoutCardGroupScope = undefined;
@@ -9644,7 +9646,7 @@ export class BrowserController {
9644
9646
  this.rememberPaymentInstrumentExpectation(card);
9645
9647
  primary = {
9646
9648
  kind: "outcome",
9647
- value: await this.submitFilledCheckoutInScope(cardGroup),
9649
+ value: await this.submitFilledCheckoutInScope(cardGroup, options.onSubmitDispatched, options.beforeSubmitDispatch),
9648
9650
  };
9649
9651
  }
9650
9652
  catch (error) {
@@ -9678,7 +9680,7 @@ export class BrowserController {
9678
9680
  // left behind. On success the filled values STAY in the page (the site
9679
9681
  // needs them at its confirm step) marked data-ts-sealed-payment, which
9680
9682
  // extractInteractiveElements reports as sealed so observations mask them.
9681
- async fillCheckoutCardFields(card) {
9683
+ async fillCheckoutCardFields(card, options = {}) {
9682
9684
  if (!this.page)
9683
9685
  throw new Error("Browser not started");
9684
9686
  this.checkoutCardGroupScope = undefined;
@@ -9687,11 +9689,10 @@ export class BrowserController {
9687
9689
  if (!recognizedPaymentProviderFrame(pageUrl, pageUrl)) {
9688
9690
  throw new Error("payment_checkout_https_required");
9689
9691
  }
9690
- // Same late-mount tolerance as fillAndSubmitCheckout: wait for a PAN
9691
- // field to appear anywhere before taking the trusted-frame snapshot below
9692
- // (which still restricts the actual fill to recognized frames — this only
9693
- // decides WHEN to look, never WHERE it's allowed to write).
9694
- 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
+ }
9695
9696
  const allowed = page
9696
9697
  .frames()
9697
9698
  .filter((frame) => frame === page.mainFrame() || recognizedPaymentProviderFrame(frame.url(), pageUrl));
@@ -9884,7 +9885,7 @@ export class BrowserController {
9884
9885
  async submitFilledCheckout() {
9885
9886
  return await this.submitFilledCheckoutInScope(this.checkoutCardGroupScope);
9886
9887
  }
9887
- async submitFilledCheckoutInScope(cardGroup) {
9888
+ async submitFilledCheckoutInScope(cardGroup, onSubmitDispatched, beforeSubmitDispatch) {
9888
9889
  if (!this.page)
9889
9890
  throw new Error("Browser not started");
9890
9891
  const savedCardSelection = await this.resolveCompetingSavedCardSelection();
@@ -9894,6 +9895,7 @@ export class BrowserController {
9894
9895
  let outcomeBaseline;
9895
9896
  this.checkoutOutcomeBaseline = undefined;
9896
9897
  let submitted = false;
9898
+ let clearSubmittedDispatchTracking = null;
9897
9899
  for (const frame of this.page.frames()) {
9898
9900
  const matches = frame.locator('button,input[type="submit"],[role="button"]');
9899
9901
  const count = Math.min(await matches.count().catch(() => 0), 100);
@@ -9943,115 +9945,147 @@ export class BrowserController {
9943
9945
  if (!CHECKOUT_SUBMIT_LABEL_RE.test(label))
9944
9946
  continue;
9945
9947
  const dispatchToken = `ts-payment-submit-${this.checkoutSubmitSequence++}`;
9946
- const dispatchBaselineStorageKey = `__trusty_squire_payment_baseline_${dispatchToken}`;
9947
9948
  const preDispatchFrameUrls = this.page.frames().map((pageFrame) => pageFrame.url());
9949
+ const clickOnlyOutcomeBaseline = checkoutOutcomeBaselineFromDispatchSnapshot({
9950
+ url: this.page.url(),
9951
+ urls: preDispatchFrameUrls,
9952
+ });
9953
+ let submitDispatchedReported = false;
9954
+ const reportSubmitDispatched = () => {
9955
+ if (submitDispatchedReported)
9956
+ return;
9957
+ onSubmitDispatched?.();
9958
+ submitDispatchedReported = true;
9959
+ };
9948
9960
  const dispatchTrackingInstalled = await candidate
9949
- .evaluate((element, options) => {
9950
- const { baselineStorageKey, frameUrls, token } = options;
9961
+ .evaluate((element, token) => {
9951
9962
  const stateWindow = window;
9952
- const tracked = element;
9953
- if (tracked.__tsPaymentSubmitDispatchListener !== undefined) {
9954
- element.removeEventListener("click", tracked.__tsPaymentSubmitDispatchListener, true);
9955
- }
9956
- stateWindow.__trustySquirePaymentSubmitDispatch = {
9957
- token,
9958
- dispatched: false,
9959
- };
9960
- const listener = () => {
9961
- const state = stateWindow.__trustySquirePaymentSubmitDispatch;
9962
- if (state?.token !== token)
9963
- return;
9964
- try {
9965
- const merchantWindow = window.top;
9966
- if (merchantWindow === null)
9967
- return;
9968
- const urls = new Set(frameUrls);
9969
- const collectFrameUrls = (currentWindow) => {
9970
- try {
9971
- urls.add(currentWindow.location.href);
9972
- for (let index = 0; index < currentWindow.frames.length; index += 1) {
9973
- collectFrameUrls(currentWindow.frames[index]);
9974
- }
9975
- }
9976
- catch (error) {
9977
- void error;
9978
- }
9979
- };
9980
- collectFrameUrls(merchantWindow);
9981
- const snapshot = {
9982
- url: merchantWindow.location.href,
9983
- urls: [...urls],
9984
- };
9985
- const baselineWindow = merchantWindow;
9986
- baselineWindow.__trustySquirePaymentDispatchBaselines ??= {};
9987
- baselineWindow.__trustySquirePaymentDispatchBaselines[token] = snapshot;
9988
- try {
9989
- merchantWindow.sessionStorage.setItem(baselineStorageKey, JSON.stringify(snapshot));
9990
- }
9991
- catch (error) {
9992
- void error;
9993
- }
9994
- }
9995
- finally {
9996
- state.dispatched = true;
9963
+ const tracked = element;
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);
9997
9968
  }
9969
+ }
9970
+ stateWindow.__trustySquirePaymentSubmitDispatch = {
9971
+ token,
9972
+ validationBlocked: false,
9998
9973
  };
9999
- tracked.__tsPaymentSubmitDispatchListener = listener;
10000
- element.addEventListener("click", listener, { capture: true, once: true });
10001
- }, {
10002
- baselineStorageKey: dispatchBaselineStorageKey,
10003
- frameUrls: preDispatchFrameUrls,
10004
- token: dispatchToken,
10005
- })
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 = () => {
9980
+ const state = stateWindow.__trustySquirePaymentSubmitDispatch;
9981
+ if (state?.token === token)
9982
+ state.validationBlocked = true;
9983
+ };
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)
10006
9998
  .then(() => true)
10007
9999
  .catch(() => false);
10008
- if (!dispatchTrackingInstalled)
10000
+ if (!dispatchTrackingInstalled) {
10009
10001
  continue;
10010
- const readDispatchOutcomeBaseline = async () => {
10011
- const snapshot = await this.page.mainFrame()
10012
- .evaluate(({ baselineStorageKey, token }) => {
10013
- const baselineWindow = window;
10014
- let captured = baselineWindow.__trustySquirePaymentDispatchBaselines?.[token] ?? null;
10015
- if (captured !== null) {
10016
- delete baselineWindow.__trustySquirePaymentDispatchBaselines?.[token];
10017
- }
10018
- if (captured === null) {
10019
- try {
10020
- const stored = sessionStorage.getItem(baselineStorageKey);
10021
- if (stored !== null) {
10022
- captured = JSON.parse(stored);
10023
- }
10024
- }
10025
- catch {
10026
- captured = null;
10027
- }
10028
- }
10029
- try {
10030
- sessionStorage.removeItem(baselineStorageKey);
10002
+ }
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;
10031
10043
  }
10032
- catch (error) {
10033
- void error;
10044
+ const challenge = await this.detectThreeDsChallenge().catch(() => undefined);
10045
+ if (challenge?.three_ds_required === true) {
10046
+ navigationThreeDsObserved = true;
10047
+ resolveNavigationOutcome();
10034
10048
  }
10035
- return captured;
10036
- }, { baselineStorageKey: dispatchBaselineStorageKey, token: dispatchToken })
10037
- .catch(() => null);
10038
- if (snapshot === null ||
10039
- typeof snapshot.url !== "string" ||
10040
- !Array.isArray(snapshot.urls) ||
10041
- !snapshot.urls.every((url) => typeof url === "string")) {
10042
- return null;
10043
- }
10044
- return checkoutOutcomeBaselineFromDispatchSnapshot(snapshot);
10049
+ })();
10050
+ };
10051
+ this.page.on("request", paymentRequestListener);
10052
+ this.page.on("framenavigated", navigationListener);
10053
+ const waitForDispatchEvidence = async () => {
10054
+ let timer;
10055
+ await Promise.race([
10056
+ concretePaymentRequest,
10057
+ navigationOutcome,
10058
+ new Promise((resolve) => {
10059
+ timer = setTimeout(resolve, CHECKOUT_PAYMENT_REQUEST_OBSERVATION_MS);
10060
+ }),
10061
+ ]);
10062
+ if (timer !== undefined)
10063
+ clearTimeout(timer);
10045
10064
  };
10065
+ const readDispatchState = async () => await frame
10066
+ .evaluate((token) => {
10067
+ const stateWindow = window;
10068
+ const state = stateWindow.__trustySquirePaymentSubmitDispatch;
10069
+ return {
10070
+ validationBlocked: state?.token === token && state.validationBlocked,
10071
+ };
10072
+ }, dispatchToken)
10073
+ .catch(() => null);
10046
10074
  const clearDispatchTracking = async () => {
10075
+ paymentRequestTrackingArmed = false;
10076
+ this.page?.off("request", paymentRequestListener);
10077
+ this.page?.off("framenavigated", navigationListener);
10047
10078
  await candidate
10048
10079
  .evaluate((element) => {
10049
10080
  const tracked = element;
10050
- if (tracked.__tsPaymentSubmitDispatchListener !== undefined) {
10051
- element.removeEventListener("click", tracked.__tsPaymentSubmitDispatchListener, true);
10052
- 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;
10053
10087
  }
10054
- })
10088
+ }, undefined, { timeout: 250 })
10055
10089
  .catch(() => undefined);
10056
10090
  await frame
10057
10091
  .evaluate((token) => {
@@ -10061,7 +10095,6 @@ export class BrowserController {
10061
10095
  }
10062
10096
  }, dispatchToken)
10063
10097
  .catch(() => undefined);
10064
- await readDispatchOutcomeBaseline();
10065
10098
  };
10066
10099
  // Money-fence boundary: the async pay-button scan above (visibility/
10067
10100
  // enabled/ownership/label checks, dispatch-tracking install) is a real
@@ -10071,42 +10104,76 @@ export class BrowserController {
10071
10104
  // can themselves trigger a merchant default-selection revert; the
10072
10105
  // re-verification is then the LAST thing before the charge click is
10073
10106
  // dispatched — never proceed on a stale check.
10074
- await this.page.bringToFront().catch(() => undefined);
10075
- if (!(await this.savedCardSelectionVerified(savedCardSelection.verification))) {
10107
+ let capturedBaseline = null;
10108
+ try {
10109
+ await this.page.bringToFront().catch(() => undefined);
10110
+ await candidate.click({ trial: true });
10111
+ if (!(await this.savedCardSelectionVerified(savedCardSelection.verification))) {
10112
+ throw new Error("payment_card_selection_ambiguous");
10113
+ }
10114
+ capturedBaseline = await runCaptureConfirmedPaymentSubmit({
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;
10124
+ markInputDispatchPossible();
10125
+ await candidate.click({
10126
+ noWaitAfter: true,
10127
+ ...(typeof remainingMs === "number"
10128
+ ? { timeout: Math.max(1, Math.ceil(remainingMs)) }
10129
+ : {}),
10130
+ });
10131
+ },
10132
+ readEvidence: async () => {
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;
10144
+ return {
10145
+ baseline: clickOnlyDispatchObserved ? clickOnlyOutcomeBaseline : null,
10146
+ dispatched: clickOnlyDispatchObserved,
10147
+ };
10148
+ },
10149
+ clear: async () => undefined,
10150
+ onSubmitDispatched: reportSubmitDispatched,
10151
+ });
10152
+ clearSubmittedDispatchTracking = clearDispatchTracking;
10153
+ }
10154
+ catch (error) {
10076
10155
  await clearDispatchTracking();
10077
- throw new Error("payment_card_selection_ambiguous");
10156
+ throw error;
10078
10157
  }
10079
10158
  try {
10080
- await candidate.click();
10159
+ outcomeBaseline = capturedBaseline ?? (await this.captureCheckoutOutcomeBaseline());
10081
10160
  }
10082
10161
  catch (error) {
10083
- const dispatchState = await frame
10084
- .evaluate((token) => {
10085
- const stateWindow = window;
10086
- const state = stateWindow.__trustySquirePaymentSubmitDispatch;
10087
- return {
10088
- sameDocument: state?.token === token,
10089
- dispatched: state?.token === token && state.dispatched,
10090
- };
10091
- }, dispatchToken)
10092
- .catch(() => null);
10093
- await clearDispatchTracking();
10094
- if (dispatchState?.sameDocument === true && !dispatchState.dispatched)
10095
- throw error;
10096
- throw new PaymentSubmitOutcomeUnknownError();
10162
+ await clearSubmittedDispatchTracking?.();
10163
+ clearSubmittedDispatchTracking = null;
10164
+ throw error;
10097
10165
  }
10098
- outcomeBaseline =
10099
- (await readDispatchOutcomeBaseline()) ?? (await this.captureCheckoutOutcomeBaseline());
10100
10166
  this.checkoutOutcomeBaseline = outcomeBaseline;
10101
- await clearDispatchTracking();
10102
10167
  submitted = true;
10103
10168
  break;
10104
10169
  }
10105
10170
  if (submitted)
10106
10171
  break;
10107
10172
  }
10108
- if (!submitted || outcomeBaseline === undefined)
10173
+ if (!submitted || outcomeBaseline === undefined) {
10174
+ await clearSubmittedDispatchTracking?.();
10109
10175
  throw new Error("payment_submit_not_found");
10176
+ }
10110
10177
  try {
10111
10178
  const challengeDeadline = Date.now() + 15_000;
10112
10179
  while (Date.now() < challengeDeadline) {
@@ -10114,17 +10181,23 @@ export class BrowserController {
10114
10181
  return { three_ds_required: false, order_confirmed: true };
10115
10182
  }
10116
10183
  const challenge = await this.detectThreeDsChallenge();
10117
- if (challenge.three_ds_required)
10184
+ if (challenge.three_ds_required) {
10118
10185
  return challenge;
10186
+ }
10119
10187
  await this.page.waitForTimeout(250).catch(() => undefined);
10120
10188
  }
10121
10189
  return { three_ds_required: false, order_confirmed: false };
10122
10190
  }
10123
10191
  catch (error) {
10124
- if (error instanceof PaymentSubmitOutcomeUnknownError)
10192
+ if (error instanceof PaymentSubmitOutcomeUnknownError ||
10193
+ error instanceof BrowserClickDispatchError) {
10125
10194
  throw error;
10195
+ }
10126
10196
  throw new PaymentSubmitOutcomeUnknownError();
10127
10197
  }
10198
+ finally {
10199
+ await clearSubmittedDispatchTracking?.();
10200
+ }
10128
10201
  }
10129
10202
  async clearSealedPaymentFields() {
10130
10203
  this.checkoutCardGroupScope = undefined;
@@ -10586,9 +10659,23 @@ export class BrowserController {
10586
10659
  catch {
10587
10660
  sameCheckoutOrigin = current.url === baseline.url;
10588
10661
  }
10589
- return (sameCheckoutOrigin &&
10662
+ const newTerminalOrderIdentity = sameCheckoutOrigin &&
10590
10663
  current.terminalUrlIdentity !== null &&
10591
- !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
+ });
10592
10679
  }
10593
10680
  // Let the browser complete the challenge natively (including out-of-band
10594
10681
  // bank-app 3DS): just poll for the same terminal-order signal a plain
@@ -10601,11 +10688,14 @@ export class BrowserController {
10601
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;
10602
10689
  const deadline = Date.now() + Math.max(timeoutMs, 0);
10603
10690
  const mismatchAtEntry = this.observedPaymentInstrumentMismatch;
10691
+ let challengeObserved = false;
10604
10692
  while (true) {
10605
10693
  await this.page.bringToFront().catch(() => undefined);
10606
- await this.detectThreeDsChallenge().catch(() => undefined);
10694
+ const challenge = await this.detectThreeDsChallenge().catch(() => undefined);
10695
+ if (challenge?.three_ds_required === true)
10696
+ challengeObserved = true;
10607
10697
  if (mismatchAtEntry === undefined && this.observedPaymentInstrumentMismatch !== undefined) {
10608
- return "timeout";
10698
+ return challengeObserved ? "challenge_pending" : "timeout";
10609
10699
  }
10610
10700
  if (await this.hasConfirmedCheckoutOutcome(outcomeBaseline))
10611
10701
  return "succeeded";
@@ -10617,7 +10707,7 @@ export class BrowserController {
10617
10707
  return "failed";
10618
10708
  const remainingMs = deadline - Date.now();
10619
10709
  if (remainingMs <= 0)
10620
- return "timeout";
10710
+ return challengeObserved ? "challenge_pending" : "timeout";
10621
10711
  await this.page.waitForTimeout(Math.min(1_000, remainingMs)).catch(() => undefined);
10622
10712
  }
10623
10713
  }
@@ -12379,6 +12469,18 @@ export class BrowserController {
12379
12469
  el instanceof HTMLTextAreaElement ||
12380
12470
  el instanceof HTMLSelectElement;
12381
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
+ };
12382
12484
  const regionName = (region) => {
12383
12485
  if (region === null)
12384
12486
  return null;
@@ -12666,8 +12768,11 @@ export class BrowserController {
12666
12768
  // matches it and the OAuth-first scan picks it up.
12667
12769
  const isGoogleGSIIframe = el instanceof HTMLIFrameElement &&
12668
12770
  (el.getAttribute("src") ?? "").includes("accounts.google.com/gsi/button");
12669
- const container = regionName(regionFor(el));
12771
+ const region = regionFor(el);
12772
+ const container = regionName(region);
12773
+ const containerId = regionId(region);
12670
12774
  const inDialog = nearestModalRegion(el) !== null;
12775
+ const formId = regionId(el.closest("form"));
12671
12776
  const status = topmostStatus(el);
12672
12777
  const pathLabel = isGoogleGSIIframe
12673
12778
  ? "Continue with Google"
@@ -12733,6 +12838,8 @@ export class BrowserController {
12733
12838
  checked: el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")
12734
12839
  ? el.checked
12735
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,
12736
12843
  // For <select>: the currently-selected option's visible text
12737
12844
  // and a short list of available option labels. The combination
12738
12845
  // is how the planner detects the "React-defaulted dropdown"
@@ -12756,12 +12863,12 @@ export class BrowserController {
12756
12863
  ? clean(el.options[el.selectedIndex]?.textContent ?? null)
12757
12864
  : null,
12758
12865
  interactedThisRun: el.getAttribute("data-ts-touched") === "1",
12759
- sealed: el.getAttribute("data-ts-sealed-payment") === "1",
12760
- sealedOrdinal: Array.from(el.getRootNode().querySelectorAll("input,textarea,select,[contenteditable='true']")).indexOf(el),
12761
12866
  screenPath: `${container ?? "body:root"} > ${elementKind(el)}:` +
12762
12867
  slug(pathLabel, `${elementKind(el)}-${out.length}`),
12763
12868
  container,
12764
12869
  inDialog,
12870
+ containerId,
12871
+ formId,
12765
12872
  topmost: status.topmost,
12766
12873
  occludedBy: status.occludedBy,
12767
12874
  });
@@ -13101,20 +13208,16 @@ export class BrowserController {
13101
13208
  const frame = await handle.ownerFrame();
13102
13209
  if (frame === null)
13103
13210
  throw new Error("type target has no owning frame");
13104
- const sealedFieldKeys = sealed
13105
- ? await this.operatorScreenshotIdentityKeys(handle, frame)
13106
- : [];
13107
13211
  if (sealed) {
13108
13212
  await handle.evaluate((el) => el.setAttribute("data-ts-sealed-payment", "1"));
13109
13213
  }
13110
13214
  if (!this.humanize) {
13111
13215
  await handle.fill(text);
13112
- return sealedFieldKeys;
13216
+ return;
13113
13217
  }
13114
13218
  await handle.click({ timeout: 8000 }).catch(() => undefined);
13115
13219
  await handle.fill("").catch(() => undefined);
13116
13220
  await handle.type(text, { delay: rand(40, 110) });
13117
- return sealedFieldKeys;
13118
13221
  }
13119
13222
  finally {
13120
13223
  await handle.dispose().catch(() => undefined);
@@ -13192,21 +13295,8 @@ export class BrowserController {
13192
13295
  const page = this.page;
13193
13296
  const mainRaw = await this.extractElementsFromContext(page);
13194
13297
  const mainGroups = assignCardRadioGroups(mainRaw.clusterMeta);
13195
- const mainDocumentIdentity = await this.sealedDocumentIdentity(page.mainFrame());
13196
13298
  const mainElements = mainRaw.out.map((e, i) => ({
13197
13299
  ...e,
13198
- sealedIdentityKeys: sealedElementSemanticKeys({
13199
- tag: e.tag,
13200
- type: e.type,
13201
- id: e.id,
13202
- name: e.name,
13203
- testId: e.testId,
13204
- labelText: e.labelText,
13205
- ariaLabel: e.ariaLabel,
13206
- placeholder: e.placeholder,
13207
- landmark: e.landmark,
13208
- ordinal: e.sealedOrdinal,
13209
- }).map((key) => `${mainDocumentIdentity}:${key}`),
13210
13300
  cardRadioGroup: mainGroups[i] ?? null,
13211
13301
  frameOrigin: null,
13212
13302
  frameUrl: null,
@@ -13238,23 +13328,10 @@ export class BrowserController {
13238
13328
  const raw = await this.extractElementsFromContext(frame);
13239
13329
  const security = await this.frameSecurity(frame);
13240
13330
  const frameOrigin = security.origin;
13241
- const frameDocumentIdentity = await this.sealedDocumentIdentity(frame);
13242
13331
  const groups = assignCardRadioGroups(raw.clusterMeta);
13243
13332
  for (const [i, e] of raw.out.entries()) {
13244
13333
  framedElements.push({
13245
13334
  ...e,
13246
- sealedIdentityKeys: sealedElementSemanticKeys({
13247
- tag: e.tag,
13248
- type: e.type,
13249
- id: e.id,
13250
- name: e.name,
13251
- testId: e.testId,
13252
- labelText: e.labelText,
13253
- ariaLabel: e.ariaLabel,
13254
- placeholder: e.placeholder,
13255
- landmark: e.landmark,
13256
- ordinal: e.sealedOrdinal,
13257
- }).map((key) => `${frameDocumentIdentity}:${key}`),
13258
13335
  cardRadioGroup: groups[i] ?? null,
13259
13336
  frameOrigin,
13260
13337
  frameUrl,
@@ -13330,7 +13407,6 @@ export class BrowserController {
13330
13407
  async startOAuth(selector) {
13331
13408
  if (!this.page || !this.context)
13332
13409
  throw new Error("Browser not started");
13333
- this.maybeAttachOAuthNetListener();
13334
13410
  if (!/accounts\.google\.com|github\.com\/login|login\.microsoftonline\.com/i.test(this.page.url())) {
13335
13411
  this.oauthProductPage = this.page;
13336
13412
  }
@@ -13367,30 +13443,46 @@ export class BrowserController {
13367
13443
  // clicks the product's already-observed OAuth control and waits for the
13368
13444
  // provider-owned window to finish; no credentials, frames, or consent scopes
13369
13445
  // are read or bypassed here.
13370
- async loginWithOAuth(selector, settleTimeoutMs = 12_000) {
13446
+ async loginWithOAuth(selector, settleTimeoutMs = 30_000, consentProvider, expectedGoogleAccountEmail) {
13371
13447
  const product = this.page;
13372
13448
  const context = this.context;
13373
13449
  if (product === null || product.isClosed() || context === null) {
13374
13450
  throw new Error("OAuth login cannot start because the product page is unavailable");
13375
13451
  }
13376
- this.maybeAttachOAuthNetListener();
13377
13452
  this.oauthProductPage = product;
13378
13453
  this.oauthProviderPage = null;
13379
13454
  this.oauthProviderPageClosed = false;
13455
+ const oauthBudgetMs = Math.max(1, settleTimeoutMs);
13380
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.`);
13381
13464
  let recovery = null;
13382
13465
  let providerPage = null;
13383
13466
  let productDeparted = false;
13467
+ let resolveProductDeparture = () => undefined;
13468
+ const productDeparturePromise = new Promise((resolve) => {
13469
+ resolveProductDeparture = resolve;
13470
+ });
13384
13471
  const onProductNavigation = (frame) => {
13385
13472
  if (frame !== product.mainFrame())
13386
13473
  return;
13387
- if (!this.isOAuthProductUrl(frame.url(), productUrl))
13474
+ if (!this.isOAuthProductUrl(frame.url(), productUrl)) {
13388
13475
  productDeparted = true;
13476
+ resolveProductDeparture();
13477
+ }
13389
13478
  };
13390
13479
  product.on("framenavigated", onProductNavigation);
13391
13480
  try {
13392
13481
  recovery = await context.newPage();
13393
- await recovery.goto(productUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
13482
+ await recovery.goto(productUrl, {
13483
+ waitUntil: "domcontentloaded",
13484
+ timeout: remainingBudgetMs(),
13485
+ });
13394
13486
  let resolvePopup = () => undefined;
13395
13487
  const popupPromise = new Promise((resolve) => {
13396
13488
  resolvePopup = resolve;
@@ -13401,6 +13493,8 @@ export class BrowserController {
13401
13493
  };
13402
13494
  context.on("page", onPopup);
13403
13495
  try {
13496
+ if (Date.now() >= oauthDeadline)
13497
+ throw deadlineError();
13404
13498
  try {
13405
13499
  await this.click(selector);
13406
13500
  }
@@ -13410,25 +13504,56 @@ export class BrowserController {
13410
13504
  }
13411
13505
  providerPage = await Promise.race([
13412
13506
  popupPromise,
13413
- 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),
13414
13512
  ]);
13415
13513
  }
13416
13514
  finally {
13417
13515
  context.off("page", onPopup);
13418
13516
  resolvePopup(null);
13517
+ resolveProductDeparture();
13419
13518
  }
13420
13519
  const transient = providerPage ?? product;
13520
+ productDeparted = productDeparted || !this.isOAuthProductUrl(transient.url(), productUrl);
13421
13521
  const durableProduct = providerPage === null ? recovery : product;
13422
13522
  this.oauthProductPage = durableProduct;
13423
13523
  this.oauthProviderPage = transient;
13424
13524
  this.oauthProviderPageClosed = transient.isClosed();
13425
13525
  this.restoreProductPageWhenOAuthPageCloses(transient, durableProduct);
13426
- 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
+ }
13427
13547
  if (settled === null) {
13428
- 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();
13429
13549
  }
13430
13550
  if (providerPage === null && product.isClosed()) {
13431
- 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
+ });
13432
13557
  }
13433
13558
  }
13434
13559
  finally {
@@ -13447,7 +13572,9 @@ export class BrowserController {
13447
13572
  if (this.page !== null && !this.page.isClosed()) {
13448
13573
  await this.page.bringToFront().catch(() => undefined);
13449
13574
  await this.page
13450
- .waitForLoadState("domcontentloaded", { timeout: 30_000 })
13575
+ .waitForLoadState("domcontentloaded", {
13576
+ timeout: remainingBudgetMs(),
13577
+ })
13451
13578
  .catch(() => undefined);
13452
13579
  }
13453
13580
  }
@@ -13487,12 +13614,23 @@ export class BrowserController {
13487
13614
  const url = page.url();
13488
13615
  const isProduct = this.isOAuthProductUrl(url, productUrl);
13489
13616
  if (startsOnProduct && departed && isProduct) {
13490
- const remaining = Math.max(1, deadline - Date.now());
13491
- const idle = await page
13492
- .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()) })
13493
13623
  .then(() => true)
13494
13624
  .catch(() => false);
13495
- 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)
13496
13634
  ? "returned"
13497
13635
  : null;
13498
13636
  }
@@ -13502,118 +13640,6 @@ export class BrowserController {
13502
13640
  }
13503
13641
  return page.isClosed() ? "closed" : null;
13504
13642
  }
13505
- // ── OAuth/SSO debug instrumentation (UNIVERSAL_BOT_OAUTH_DEBUG) ──
13506
- // Attach a context-level response recorder ONCE. Records SSO-relevant
13507
- // responses (the service host + Clerk/Stytch/WorkOS FAPI hosts) with their
13508
- // status + whether they set a cookie — the signal for "did the callback's
13509
- // session-establish request succeed and set the session cookie?"
13510
- maybeAttachOAuthNetListener() {
13511
- if (this.oauthNetListenerAttached)
13512
- return;
13513
- if (!/^(1|true|on)$/i.test(process.env.UNIVERSAL_BOT_OAUTH_DEBUG ?? ""))
13514
- return;
13515
- if (!this.context)
13516
- return;
13517
- this.oauthNetListenerAttached = true;
13518
- const RELEVANT = /clerk|stytch|workos|accounts\.|\/sso|\/oauth|\/session|\/sign|callback|\/v1\/client/i;
13519
- this.context.on("response", (res) => {
13520
- void (async () => {
13521
- try {
13522
- const url = res.url();
13523
- if (!RELEVANT.test(url))
13524
- return;
13525
- const headers = res.headers();
13526
- if (this.oauthNetLog.length >= 200)
13527
- return;
13528
- const entry = {
13529
- url: url.slice(0, 200),
13530
- status: res.status(),
13531
- setCookie: "set-cookie" in headers || "Set-Cookie" in headers,
13532
- ct: (headers["content-type"] ?? "").slice(0, 40),
13533
- };
13534
- // Capture the body of a Clerk/Stytch/WorkOS sign-in/up/callback error
13535
- // (>=400) — its error code is the definitive tell (captcha_invalid vs
13536
- // transfer vs identifier_*). JSON only, bounded.
13537
- if (res.status() >= 400 &&
13538
- /\/v1\/client\/(sign_ins|sign_ups)|oauth_callback|\/session/i.test(url)) {
13539
- entry.body = (await res.text().catch(() => "")).slice(0, 800);
13540
- }
13541
- this.oauthNetLog.push(entry);
13542
- }
13543
- catch {
13544
- // best-effort observability — never perturb the run
13545
- }
13546
- })();
13547
- });
13548
- }
13549
- // Dump cookies + the SSO network log to a file for post-mortem. Called at the
13550
- // oauth_session_not_persisted decision point when UNIVERSAL_BOT_OAUTH_DEBUG.
13551
- async dumpOAuthDebug(service, label) {
13552
- if (!/^(1|true|on)$/i.test(process.env.UNIVERSAL_BOT_OAUTH_DEBUG ?? ""))
13553
- return;
13554
- if (!this.context)
13555
- return;
13556
- try {
13557
- const cookies = await this.context.cookies();
13558
- const cookieSummary = cookies.map((c) => ({
13559
- name: c.name,
13560
- domain: c.domain,
13561
- path: c.path,
13562
- len: c.value.length,
13563
- httpOnly: c.httpOnly,
13564
- secure: c.secure,
13565
- sameSite: c.sameSite,
13566
- }));
13567
- const url = this.page ? this.page.url() : "(no page)";
13568
- // Capture the live Clerk SDK state — the definitive read on whether a
13569
- // sign-up transfer is available (the new-user-OAuth fix hinges on this).
13570
- const clerkState = this.page
13571
- ? await this.page
13572
- .evaluate(() => {
13573
- const w = window;
13574
- const c = w.Clerk;
13575
- if (c === undefined)
13576
- return { present: false };
13577
- const client = c.client;
13578
- const si = client?.["signIn"];
13579
- const su = client?.["signUp"];
13580
- const ffv = si?.["firstFactorVerification"];
13581
- return {
13582
- present: true,
13583
- loaded: c.loaded ?? null,
13584
- signInStatus: si?.["status"] ?? null,
13585
- signInFFVStatus: ffv?.["status"] ?? null,
13586
- signInFFVError: ffv?.["error"] ?? null,
13587
- signUpStatus: su?.["status"] ?? null,
13588
- signUpMissingFields: su?.["missingFields"] ?? null,
13589
- hasSignUpCreate: typeof su?.create === "function",
13590
- };
13591
- })
13592
- .catch((e) => ({ present: "evalError", err: String(e).slice(0, 120) }))
13593
- : { present: false };
13594
- const consoleText = await this.extractText().catch(() => "");
13595
- const { writeFileSync, mkdirSync } = await import("node:fs");
13596
- const { join } = await import("node:path");
13597
- const { homedir } = await import("node:os");
13598
- const dir = join(homedir(), ".trusty-squire", "oauth-debug");
13599
- mkdirSync(dir, { recursive: true });
13600
- const ts = process.env.OAUTH_DEBUG_TS ?? String(this.oauthNetLog.length);
13601
- const path = join(dir, `${service}-${label}-${ts}.json`);
13602
- writeFileSync(path, JSON.stringify({
13603
- service,
13604
- label,
13605
- finalUrl: url,
13606
- clerkState,
13607
- cookies: cookieSummary,
13608
- netLog: this.oauthNetLog,
13609
- pageText: consoleText.slice(0, 600),
13610
- }, null, 2));
13611
- console.error(`[oauth-debug] wrote ${path} (${cookieSummary.length} cookies, ${this.oauthNetLog.length} net entries)`);
13612
- }
13613
- catch (err) {
13614
- console.error(`[oauth-debug] dump failed: ${err instanceof Error ? err.message : String(err)}`);
13615
- }
13616
- }
13617
13643
  // Read the page's Rails/OmniAuth CSRF token (<meta name="csrf-token">).
13618
13644
  // Needed to recover OmniAuth 2.0 POST-only OAuth when the provider button is
13619
13645
  // a GET <a> that page-JS upgrades to a POST.
@@ -13862,6 +13888,67 @@ export class BrowserController {
13862
13888
  recoverActivePage() {
13863
13889
  return this.adoptLivePage();
13864
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
+ }
13865
13952
  adoptLivePage() {
13866
13953
  if (this.page !== null && !this.page.isClosed())
13867
13954
  return true;
@@ -14099,14 +14186,7 @@ export class BrowserController {
14099
14186
  return false;
14100
14187
  }
14101
14188
  }
14102
- // Which OAuth providers have a LIVE session in this profile's cookie jar.
14103
- // The logged-in-providers.json marker is a memo that drifts out of sync
14104
- // (a --force-relogin clears it, a misclassified run clears it, a parallel
14105
- // run overwrites it) — so a session that is genuinely live in the cookies
14106
- // can go invisible to provider selection, which is exactly how a warm
14107
- // GitHub session got skipped in favour of a broken Google path. The cookie
14108
- // jar is the ground truth: read it directly. Cookie NAMES + presence only;
14109
- // 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.
14110
14190
  async detectSessionProviders() {
14111
14191
  if (this.context === null)
14112
14192
  return [];
@@ -14117,13 +14197,18 @@ export class BrowserController {
14117
14197
  return [];
14118
14198
  }
14119
14199
  }
14120
- async detectGoogleAccountEmail() {
14200
+ async detectGoogleAccountEmail(expectedGoogleAccountEmail) {
14121
14201
  if (this.context === null)
14122
14202
  return null;
14123
14203
  let identityPage = null;
14124
14204
  try {
14125
14205
  identityPage = await this.context.newPage();
14126
- 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, {
14127
14212
  waitUntil: "domcontentloaded",
14128
14213
  timeout: 20_000,
14129
14214
  });
@@ -14147,13 +14232,18 @@ export class BrowserController {
14147
14232
  await identityPage?.close().catch(() => undefined);
14148
14233
  }
14149
14234
  }
14150
- // Advance a provider's consent / account-chooser screen by one click
14151
- // — 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
14152
14237
  // approve control is present — the agent then aborts rather than
14153
14238
  // hang. Clicks only; never types (the critical guarantee holds here).
14154
- async advanceOAuthConsent(provider) {
14239
+ async advanceOAuthConsent(provider, timeoutMs = 8_000, expectedGoogleAccountEmail) {
14155
14240
  if (!this.page)
14156
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;
14157
14247
  if (provider === "github") {
14158
14248
  // GitHub App install flow can include an account target chooser before
14159
14249
  // the Install/Authorize screen:
@@ -14163,8 +14253,12 @@ export class BrowserController {
14163
14253
  // consent loop re-classify the next GitHub page.
14164
14254
  if (/\/apps\/[^/]+\/installations\/select_target\b/.test(new URL(this.page.url()).pathname)) {
14165
14255
  const startUrl = this.page.url();
14256
+ if (!hasBudget())
14257
+ return false;
14166
14258
  const clicked = await this.page
14167
- .evaluate(() => {
14259
+ .evaluate((expiresAt) => {
14260
+ if (Date.now() >= expiresAt)
14261
+ return false;
14168
14262
  const visible = (el) => {
14169
14263
  const r = el.getBoundingClientRect();
14170
14264
  const s = window.getComputedStyle(el);
@@ -14189,13 +14283,17 @@ export class BrowserController {
14189
14283
  });
14190
14284
  if (target === undefined)
14191
14285
  return false;
14286
+ if (Date.now() >= expiresAt)
14287
+ return false;
14192
14288
  target.click();
14193
14289
  return true;
14194
- })
14290
+ }, deadline)
14195
14291
  .catch(() => false);
14196
14292
  if (clicked) {
14197
14293
  const advanced = await this.page
14198
- .waitForFunction((s) => window.location.href !== s, startUrl, { timeout: 8000 })
14294
+ .waitForFunction((s) => window.location.href !== s, startUrl, {
14295
+ timeout: boundedTimeout(8_000),
14296
+ })
14199
14297
  .then(() => true)
14200
14298
  .catch(() => false);
14201
14299
  if (advanced)
@@ -14230,7 +14328,7 @@ export class BrowserController {
14230
14328
  // (MEASURED 2026-06-11: defang's "Authorize DefangLabs"). Poll up to
14231
14329
  // 12s for it to enable before clicking.
14232
14330
  {
14233
- const deadline = Date.now() + 12_000;
14331
+ const deadline = Date.now() + boundedTimeout(12_000);
14234
14332
  while (Date.now() < deadline) {
14235
14333
  const disabled = await btn
14236
14334
  .evaluate((el) => {
@@ -14247,8 +14345,10 @@ export class BrowserController {
14247
14345
  await this.sleep(400);
14248
14346
  }
14249
14347
  }
14348
+ if (!hasBudget())
14349
+ return false;
14250
14350
  try {
14251
- await btn.click({ timeout: 8000 });
14351
+ await btn.click({ timeout: boundedTimeout(8_000) });
14252
14352
  }
14253
14353
  catch {
14254
14354
  continue;
@@ -14258,7 +14358,9 @@ export class BrowserController {
14258
14358
  // click silently failed (wrong element, or button disabled
14259
14359
  // behind a hidden iframe). Return false so the caller knows.
14260
14360
  const advanced = await this.page
14261
- .waitForFunction((s) => window.location.href !== s, startUrl, { timeout: 4000 })
14361
+ .waitForFunction((s) => window.location.href !== s, startUrl, {
14362
+ timeout: boundedTimeout(4_000),
14363
+ })
14262
14364
  .then(() => true)
14263
14365
  .catch(() => false);
14264
14366
  if (advanced)
@@ -14291,10 +14393,89 @@ export class BrowserController {
14291
14393
  }
14292
14394
  // Google. Account chooser: Google renders each account with a
14293
14395
  // stable data-identifier attribute (the account email).
14294
- const tile = this.page.locator("[data-identifier]").first();
14295
- 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;
14296
14477
  try {
14297
- await tile.click({ timeout: 8000 });
14478
+ await accountRows.nth(accountRowIndex).click({ timeout: boundedTimeout(1_000) });
14298
14479
  return true;
14299
14480
  }
14300
14481
  catch {
@@ -14312,14 +14493,16 @@ export class BrowserController {
14312
14493
  const APPROVE_NAME = /^(?:continue|allow|accept|agree)\b/i;
14313
14494
  const approve = this.page.getByRole("button", { name: APPROVE_NAME }).first();
14314
14495
  try {
14315
- await approve.waitFor({ state: "visible", timeout: 8000 });
14496
+ await approve.waitFor({ state: "visible", timeout: boundedTimeout(1_000) });
14316
14497
  }
14317
14498
  catch {
14318
14499
  // not visible within the window — fall through to the DOM-scan path
14319
14500
  }
14320
14501
  if ((await approve.count().catch(() => 0)) > 0) {
14502
+ if (!hasBudget())
14503
+ return false;
14321
14504
  try {
14322
- await approve.click({ timeout: 8000 });
14505
+ await approve.click({ timeout: boundedTimeout(1_000) });
14323
14506
  return true;
14324
14507
  }
14325
14508
  catch {
@@ -14331,8 +14514,12 @@ export class BrowserController {
14331
14514
  // <div role>/<span> or an <input type=submit value="Allow access">).
14332
14515
  // Click the first visible candidate whose text is an approve verb and
14333
14516
  // is NOT a cancel/deny/back. Log what was visible on failure.
14517
+ if (!hasBudget())
14518
+ return false;
14334
14519
  const clicked = await this.page
14335
- .evaluate(() => {
14520
+ .evaluate((expiresAt) => {
14521
+ if (Date.now() >= expiresAt)
14522
+ return null;
14336
14523
  const APPROVE = /^(?:continue|allow|accept|agree)\b/i;
14337
14524
  const DENY = /\b(?:cancel|deny|back|no\b|not now|reject)\b/i;
14338
14525
  const els = Array.from(document.querySelectorAll('button, input[type="submit"], [role="button"], a[href]'));
@@ -14346,12 +14533,14 @@ export class BrowserController {
14346
14533
  if (DENY.test(t))
14347
14534
  continue;
14348
14535
  if (APPROVE.test(t)) {
14536
+ if (Date.now() >= expiresAt)
14537
+ return null;
14349
14538
  el.click();
14350
14539
  return t.slice(0, 40);
14351
14540
  }
14352
14541
  }
14353
14542
  return null;
14354
- })
14543
+ }, deadline)
14355
14544
  .catch(() => null);
14356
14545
  if (clicked !== null)
14357
14546
  return true;
@@ -14431,51 +14620,6 @@ export class BrowserController {
14431
14620
  this.oauthProviderPageClosed = false;
14432
14621
  }
14433
14622
  }
14434
- // End one sequential task without tearing down Chrome. The original,
14435
- // handler-bound page survives; OAuth popups and any other incidental tabs are
14436
- // closed. about:blank replaces the task document while the persistent profile
14437
- // (cookies, OAuth identity, local profile data) intentionally remains intact.
14438
- async resetPageForReuse() {
14439
- if (!this.profilePoolReusable)
14440
- throw new Error("browser profile is not pool-reusable");
14441
- const context = this.context;
14442
- const primaryPage = this.primaryPage;
14443
- if (!this.isConnected() || context === null || primaryPage === null || primaryPage.isClosed()) {
14444
- throw new Error("warm browser page is not reusable");
14445
- }
14446
- this.page = primaryPage;
14447
- this.oauthProductPage = null;
14448
- this.oauthProviderPage = null;
14449
- this.oauthProviderPageClosed = false;
14450
- this.oauthNetLog = [];
14451
- this.mouseX = 100;
14452
- this.mouseY = 100;
14453
- for (const page of context.pages()) {
14454
- if (page !== primaryPage && !page.isClosed()) {
14455
- let timer;
14456
- try {
14457
- await Promise.race([
14458
- page.close(),
14459
- new Promise((_, reject) => {
14460
- timer = setTimeout(() => reject(new Error("timed out closing warm browser popup")), 5_000);
14461
- timer.unref();
14462
- }),
14463
- ]);
14464
- }
14465
- finally {
14466
- if (timer !== undefined)
14467
- clearTimeout(timer);
14468
- }
14469
- }
14470
- }
14471
- if (context.pages().some((page) => page !== primaryPage && !page.isClosed())) {
14472
- throw new Error("warm browser reset left a non-primary page open");
14473
- }
14474
- await primaryPage.goto("about:blank", {
14475
- waitUntil: "domcontentloaded",
14476
- timeout: 30_000,
14477
- });
14478
- }
14479
14623
  async close(options = {}) {
14480
14624
  if (options.cancelStart === true) {
14481
14625
  this.startCancellationRequested = true;
@@ -14485,36 +14629,59 @@ export class BrowserController {
14485
14629
  this.closePromise ??= this.closeAfterStart();
14486
14630
  return await this.closePromise;
14487
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
+ }
14641
+ async forceCloseOwnedProcessTree() {
14642
+ this.startCancellationRequested = true;
14643
+ this.resolveStartCancellation?.();
14644
+ this.resolveStartCancellation = null;
14645
+ const marker = this.operatorBrowserMarker();
14646
+ if (this.ownerLaunchTracked)
14647
+ markOwnerBrowserLaunchTerminal(marker);
14648
+ const proof = this.ownedChromeProcessTreeProof;
14649
+ const identity = proof?.identity ?? this.currentOwnedProfileIdentity();
14650
+ if (identity !== null) {
14651
+ signalOwnedChromeProcessTree(identity, proof?.processGroup ?? false, "SIGKILL", {
14652
+ ...(proof === null ? {} : { proof }),
14653
+ });
14654
+ reapProfileHolderIfOwned(this.profileDir, identity);
14655
+ }
14656
+ const closed = identity === null
14657
+ ? this.startSettled && !this.launchedContext
14658
+ : await this.waitForOwnedProfileExit(identity, proof);
14659
+ if (closed && proof !== null) {
14660
+ releaseOwnedChromeProcessTree(proof);
14661
+ const tracked = selfManagedChromes.get(proof.identity.pid);
14662
+ if (tracked?.proof === proof)
14663
+ selfManagedChromes.delete(proof.identity.pid);
14664
+ if (this.ownedChromeProcessTreeProof === proof)
14665
+ this.ownedChromeProcessTreeProof = null;
14666
+ }
14667
+ const markerClosed = !this.ownerLaunchTracked || (await terminateOwnerBrowserLaunch(marker, this.profileDir));
14668
+ if (closed && markerClosed && this.ownerLaunchTracked) {
14669
+ untrackOwnerBrowserLaunch(marker);
14670
+ this.ownerLaunchTracked = false;
14671
+ }
14672
+ await this.teardownOwnedDisplay().catch(() => undefined);
14673
+ return closed && markerClosed ? "closed" : "unknown";
14674
+ }
14488
14675
  async closeCancelledStart() {
14489
- if (this.persistentFallbackLaunchInFlight) {
14490
- await this.startPromise?.catch(() => undefined);
14491
- }
14676
+ void this.reapCancelledStartProcess().catch(() => undefined);
14492
14677
  if (this.persistentFallbackCancellationState !== null) {
14493
- const closeState = this.persistentFallbackCancellationState;
14494
- if (closeState === "closed") {
14495
- const lease = this.profileOperationLease;
14496
- this.profileOperationLease = null;
14497
- lease?.release();
14498
- }
14499
- return closeState;
14678
+ return this.persistentFallbackCancellationState;
14500
14679
  }
14501
14680
  if (!this.startLaunchCommitted) {
14502
- await this.closeWithProfileGuard();
14503
- const lease = this.profileOperationLease;
14504
- this.profileOperationLease = null;
14505
- lease?.release();
14506
- return "closed";
14507
- }
14508
- const [closeState] = await Promise.all([
14509
- this.closeWithProfileGuard(),
14510
- this.reapCancelledStartProcess(),
14511
- ]);
14512
- if (this.startSettled) {
14513
- const lease = this.profileOperationLease;
14514
- this.profileOperationLease = null;
14515
- lease?.release();
14681
+ const closeState = await this.closeBrowser();
14682
+ return this.startSettled ? closeState : "unknown";
14516
14683
  }
14517
- return closeState;
14684
+ return await this.closeBrowser();
14518
14685
  }
14519
14686
  async reapCancelledStartProcess() {
14520
14687
  this.cancelledStartReaper ??= this.monitorCancelledStartProcess();
@@ -14524,36 +14691,107 @@ export class BrowserController {
14524
14691
  while (!this.startSettled) {
14525
14692
  const identity = this.currentOwnedProfileIdentity();
14526
14693
  if (identity !== null) {
14527
- signalProfileProcess(identity, this.profileDir, "SIGKILL");
14694
+ this.signalCurrentSelfManagedChrome(identity, "SIGKILL");
14528
14695
  reapProfileHolderIfOwned(this.profileDir, identity);
14529
14696
  }
14530
14697
  await new Promise((resolveWait) => {
14531
- setTimeout(resolveWait, 25);
14698
+ const timer = setTimeout(resolveWait, 25);
14699
+ timer.unref();
14532
14700
  });
14533
14701
  }
14534
14702
  }
14535
14703
  currentOwnedProfileIdentity() {
14536
- const known = this.childChromeIdentity ?? this.launchedProfileHolderIdentity;
14704
+ const known = this.ownedChromeProcessTreeProof?.identity ??
14705
+ this.childChromeIdentity ??
14706
+ this.launchedProfileHolderIdentity;
14537
14707
  if (known !== null)
14538
14708
  return known;
14539
14709
  const holderPid = currentProfileHolderPid(this.profileDir);
14540
- return holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir);
14710
+ if (holderPid === null)
14711
+ return null;
14712
+ const identity = profileProcessIdentity(holderPid, this.profileDir);
14713
+ if (identity === null)
14714
+ return null;
14715
+ if (!this.startCancellationRequested)
14716
+ return identity;
14717
+ return operatorBrowserProcessMatchesMarker(identity.pid, this.operatorBrowserMarker())
14718
+ ? identity
14719
+ : null;
14541
14720
  }
14542
14721
  async waitForPersistentFallbackIdentity() {
14543
- return await resolvePersistentFallbackIdentity({ profileDir: this.profileDir });
14722
+ if (this.ownedChromeProcessTreeProof !== null) {
14723
+ return { state: "owned", identity: this.ownedChromeProcessTreeProof.identity };
14724
+ }
14725
+ const proof = await resolvePersistentFallbackIdentity({ profileDir: this.profileDir });
14726
+ if (proof.state === "owned" &&
14727
+ this.startCancellationRequested &&
14728
+ !operatorBrowserProcessMatchesMarker(proof.identity.pid, this.operatorBrowserMarker())) {
14729
+ return { state: "unknown" };
14730
+ }
14731
+ if (proof.state === "owned")
14732
+ this.adoptOwnedChromeProcessTree(proof.identity, false);
14733
+ return proof;
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
+ }
14749
+ startPersistentFallbackOwnershipMonitor() {
14750
+ if (this.persistentFallbackOwnershipMonitor !== null)
14751
+ return;
14752
+ this.persistentFallbackOwnershipMonitor = (async () => {
14753
+ while (this.persistentFallbackLaunchInFlight && this.ownedChromeProcessTreeProof === null) {
14754
+ const holderPid = currentProfileHolderPid(this.profileDir);
14755
+ const identity = holderPid === null ? null : profileProcessIdentity(holderPid, this.profileDir);
14756
+ const controllerOwnsIdentity = identity !== null &&
14757
+ (!this.startCancellationRequested ||
14758
+ operatorBrowserProcessMatchesMarker(identity.pid, this.operatorBrowserMarker()));
14759
+ if (identity !== null && controllerOwnsIdentity) {
14760
+ this.launchedProfileHolderIdentity = identity;
14761
+ try {
14762
+ this.adoptOwnedChromeProcessTree(identity, false);
14763
+ }
14764
+ catch { }
14765
+ return;
14766
+ }
14767
+ await new Promise((resolveWait) => {
14768
+ const timer = setTimeout(resolveWait, PROFILE_IDENTITY_POLL_MS);
14769
+ timer.unref();
14770
+ });
14771
+ }
14772
+ })();
14544
14773
  }
14545
- async waitForOwnedProfileExit(identity) {
14774
+ async waitForOwnedProfileExit(identity, existingProof) {
14546
14775
  const deadline = Date.now() + PROFILE_IDENTITY_PROOF_TIMEOUT_MS;
14547
- let state = profileProcessIdentityState(identity, this.profileDir);
14776
+ const proof = existingProof ?? captureOwnedChromeProcessTreeProof(identity, false);
14777
+ let state = proof === null
14778
+ ? profileProcessIdentityState(identity, this.profileDir)
14779
+ : ownedChromeProcessTreeState(proof);
14548
14780
  while (state !== "stale" && Date.now() < deadline) {
14549
- if (profileProcessMatches(identity, this.profileDir)) {
14550
- signalProfileProcess(identity, this.profileDir, "SIGKILL");
14781
+ if (proof !== null) {
14782
+ signalOwnedChromeProcessTree(identity, false, "SIGKILL", { proof });
14783
+ }
14784
+ else if (profileProcessMatches(identity, this.profileDir)) {
14785
+ signalOwnedChromeProcessTree(identity, false, "SIGKILL");
14551
14786
  }
14552
14787
  await new Promise((resolveWait) => {
14553
14788
  const timer = setTimeout(resolveWait, PROFILE_IDENTITY_POLL_MS);
14554
14789
  timer.unref();
14555
14790
  });
14556
- state = profileProcessIdentityState(identity, this.profileDir);
14791
+ state =
14792
+ proof === null
14793
+ ? profileProcessIdentityState(identity, this.profileDir)
14794
+ : ownedChromeProcessTreeState(proof);
14557
14795
  }
14558
14796
  if (state !== "stale")
14559
14797
  return false;
@@ -14566,31 +14804,25 @@ export class BrowserController {
14566
14804
  }
14567
14805
  if (this.startCancellationRequested)
14568
14806
  return await this.closeCancelledStart();
14569
- try {
14570
- return await this.closeWithProfileGuard();
14571
- }
14572
- finally {
14573
- const lease = this.profileOperationLease;
14574
- this.profileOperationLease = null;
14575
- lease?.release();
14576
- }
14807
+ return await this.closeBrowser();
14577
14808
  }
14578
- async closeWithProfileGuard() {
14809
+ async closeBrowser() {
14579
14810
  if (this.harnessAttachedPage) {
14580
14811
  this.page = null;
14581
14812
  this.primaryPage = null;
14582
14813
  this.oauthProductPage = null;
14583
14814
  this.oauthProviderPage = null;
14584
14815
  this.oauthProviderPageClosed = false;
14585
- this.oauthNetLog = [];
14586
14816
  this.context = null;
14587
14817
  return "closed";
14588
14818
  }
14819
+ const marker = this.operatorBrowserMarker();
14820
+ if (this.ownerLaunchTracked)
14821
+ markOwnerBrowserLaunchTerminal(marker);
14589
14822
  // Each step is best-effort and independent: a throw closing the page
14590
- // or context must NOT skip the Xvfb teardown below, or the virtual
14591
- // display leaks (orphaned Xvfb procs pile up over a long-lived MCP
14592
- // server and, worse, the un-closed Chrome keeps the profile's
14593
- // 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`).
14594
14826
  //
14595
14827
  // EVERY close call is timeout-capped. On a wedged headed Chrome (e.g. a
14596
14828
  // run that crashed mid-captcha-click), BOTH page.close() AND
@@ -14603,60 +14835,73 @@ export class BrowserController {
14603
14835
  const context = this.context;
14604
14836
  const cdpBrowser = this.cdpBrowser;
14605
14837
  const childIdentity = this.childChromeIdentity;
14838
+ const childChromeProcessGroup = this.childChromeProcessGroup;
14606
14839
  const holderIdentity = this.launchedProfileHolderIdentity ?? this.currentOwnedProfileIdentity();
14607
- const identity = childIdentity ?? holderIdentity;
14840
+ const identity = this.ownedChromeProcessTreeProof?.identity ?? childIdentity ?? holderIdentity;
14841
+ const treeProof = identity === null
14842
+ ? null
14843
+ : (this.ownedChromeProcessTreeProof ??
14844
+ this.adoptOwnedChromeProcessTree(identity, childIdentity !== null ? childChromeProcessGroup : false));
14608
14845
  this.page = null;
14609
14846
  this.primaryPage = null;
14610
14847
  this.oauthProductPage = null;
14611
14848
  this.oauthProviderPage = null;
14612
14849
  this.oauthProviderPageClosed = false;
14613
- this.oauthNetLog = [];
14614
14850
  this.context = null;
14615
14851
  this.cdpBrowser = null;
14616
14852
  this.childChrome = null;
14617
14853
  this.childChromeIdentity = null;
14854
+ this.childChromeProcessGroup = false;
14618
14855
  this.launchedContext = false;
14619
14856
  this.launchedProfileHolderIdentity = null;
14620
14857
  const closeState = await closeProfileWithProof({
14621
14858
  profileDir: this.profileDir,
14622
14859
  identity,
14623
14860
  close: async () => {
14861
+ if (identity !== null) {
14862
+ signalOwnedChromeProcessTree(identity, treeProof?.processGroup ?? (childIdentity !== null ? childChromeProcessGroup : false), "SIGTERM", { ...(treeProof === null ? {} : { proof: treeProof }) });
14863
+ }
14864
+ // A process-tree SIGTERM can close the CDP target before Playwright
14865
+ // observes it. That is successful teardown, not a reason to skip the
14866
+ // proof/reap path or retain a cleanly closed ephemeral profile.
14624
14867
  if (page !== null)
14625
- await page.close();
14868
+ await page.close().catch(() => undefined);
14626
14869
  if (context !== null)
14627
- await context.close();
14870
+ await context.close().catch(() => undefined);
14628
14871
  if (cdpBrowser !== null)
14629
- await cdpBrowser.close();
14630
- if (childIdentity !== null) {
14631
- signalProfileProcess(childIdentity, this.profileDir, "SIGTERM");
14632
- }
14872
+ await cdpBrowser.close().catch(() => undefined);
14633
14873
  },
14634
14874
  forceClose: () => {
14635
- if (identity !== null)
14636
- signalProfileProcess(identity, this.profileDir, "SIGKILL");
14875
+ if (identity !== null) {
14876
+ signalOwnedChromeProcessTree(identity, treeProof?.processGroup ?? (childIdentity !== null ? childChromeProcessGroup : false), "SIGKILL", { ...(treeProof === null ? {} : { proof: treeProof }) });
14877
+ }
14637
14878
  reapProfileHolderIfOwned(this.profileDir, identity);
14638
14879
  },
14880
+ ...(treeProof === null
14881
+ ? {}
14882
+ : { identityState: () => ownedChromeProcessTreeState(treeProof) }),
14639
14883
  });
14640
14884
  // Self-launch path: disconnect the CDP browser and SIGKILL the Chrome we
14641
14885
  // spawned. context.close() on a connectOverCDP context only disconnects —
14642
14886
  // it does NOT necessarily exit the browser process, which would leak the
14643
14887
  // SingletonLock and brick the next run (the reap below is the backstop, but
14644
14888
  // killing our own child directly is cleaner and faster).
14645
- if (childIdentity !== null)
14646
- selfManagedChromes.delete(childIdentity.pid);
14647
- // F13 — release the on-demand Xvfb if we spawned one. Order
14648
- // matters: kill Chrome (context.close) first so it has its
14649
- // display until it exits, THEN kill Xvfb.
14650
- if (this.xvfb !== null) {
14651
- try {
14652
- this.xvfb.stop();
14653
- }
14654
- catch {
14655
- /* best-effort */
14656
- }
14657
- this.xvfb = null;
14658
- }
14659
- return closeState;
14889
+ if (treeProof !== null && ownedChromeProcessTreeState(treeProof) === "stale") {
14890
+ releaseOwnedChromeProcessTree(treeProof);
14891
+ const tracked = selfManagedChromes.get(treeProof.identity.pid);
14892
+ if (tracked?.proof === treeProof)
14893
+ selfManagedChromes.delete(treeProof.identity.pid);
14894
+ if (this.ownedChromeProcessTreeProof === treeProof) {
14895
+ this.ownedChromeProcessTreeProof = null;
14896
+ }
14897
+ }
14898
+ const markerClosed = !this.ownerLaunchTracked || (await terminateOwnerBrowserLaunch(marker, this.profileDir));
14899
+ if (markerClosed && this.ownerLaunchTracked) {
14900
+ untrackOwnerBrowserLaunch(marker);
14901
+ this.ownerLaunchTracked = false;
14902
+ }
14903
+ await this.teardownOwnedDisplay().catch(() => undefined);
14904
+ return closeState === "closed" && !markerClosed ? "force_closed_unproven" : closeState;
14660
14905
  }
14661
14906
  }
14662
14907
  // Random integer in [min, max]. We use Math.random() (not crypto)