@trusty-squire/mcp 1.1.6 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +34 -16
  2. package/dist/api-client.d.ts +11 -3
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +14 -4
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/bot/browser.d.ts +19 -1
  7. package/dist/bot/browser.d.ts.map +1 -1
  8. package/dist/bot/browser.js +228 -30
  9. package/dist/bot/browser.js.map +1 -1
  10. package/dist/bot/near-text-hint.d.ts +7 -3
  11. package/dist/bot/near-text-hint.d.ts.map +1 -1
  12. package/dist/bot/near-text-hint.js +1 -2
  13. package/dist/bot/near-text-hint.js.map +1 -1
  14. package/dist/bot/operator-recipe.d.ts +694 -36
  15. package/dist/bot/operator-recipe.d.ts.map +1 -1
  16. package/dist/bot/operator-recipe.js +565 -43
  17. package/dist/bot/operator-recipe.js.map +1 -1
  18. package/dist/bot/pay-operator.d.ts +4 -2
  19. package/dist/bot/pay-operator.d.ts.map +1 -1
  20. package/dist/bot/pay-operator.js +173 -67
  21. package/dist/bot/pay-operator.js.map +1 -1
  22. package/dist/bot/promote-to-skill.d.ts +1 -0
  23. package/dist/bot/promote-to-skill.d.ts.map +1 -1
  24. package/dist/bot/promote-to-skill.js +20 -30
  25. package/dist/bot/promote-to-skill.js.map +1 -1
  26. package/dist/bot/provision-session.d.ts +44 -1
  27. package/dist/bot/provision-session.d.ts.map +1 -1
  28. package/dist/bot/provision-session.js +1300 -104
  29. package/dist/bot/provision-session.js.map +1 -1
  30. package/dist/eval/replay-harness/corpus.d.ts +7 -0
  31. package/dist/eval/replay-harness/corpus.d.ts.map +1 -0
  32. package/dist/eval/replay-harness/corpus.js +183 -0
  33. package/dist/eval/replay-harness/corpus.js.map +1 -0
  34. package/dist/eval/replay-harness/har-mutate.d.ts +36 -0
  35. package/dist/eval/replay-harness/har-mutate.d.ts.map +1 -0
  36. package/dist/eval/replay-harness/har-mutate.js +119 -0
  37. package/dist/eval/replay-harness/har-mutate.js.map +1 -0
  38. package/dist/eval/replay-harness/har-substrate.d.ts +16 -0
  39. package/dist/eval/replay-harness/har-substrate.d.ts.map +1 -0
  40. package/dist/eval/replay-harness/har-substrate.js +99 -0
  41. package/dist/eval/replay-harness/har-substrate.js.map +1 -0
  42. package/dist/eval/replay-harness/index.d.ts +8 -0
  43. package/dist/eval/replay-harness/index.d.ts.map +1 -0
  44. package/dist/eval/replay-harness/index.js +8 -0
  45. package/dist/eval/replay-harness/index.js.map +1 -0
  46. package/dist/eval/replay-harness/metrics.d.ts +9 -0
  47. package/dist/eval/replay-harness/metrics.d.ts.map +1 -0
  48. package/dist/eval/replay-harness/metrics.js +117 -0
  49. package/dist/eval/replay-harness/metrics.js.map +1 -0
  50. package/dist/eval/replay-harness/reporter.d.ts +4 -0
  51. package/dist/eval/replay-harness/reporter.d.ts.map +1 -0
  52. package/dist/eval/replay-harness/reporter.js +62 -0
  53. package/dist/eval/replay-harness/reporter.js.map +1 -0
  54. package/dist/eval/replay-harness/runner.d.ts +36 -0
  55. package/dist/eval/replay-harness/runner.d.ts.map +1 -0
  56. package/dist/eval/replay-harness/runner.js +167 -0
  57. package/dist/eval/replay-harness/runner.js.map +1 -0
  58. package/dist/eval/replay-harness/types.d.ts +135 -0
  59. package/dist/eval/replay-harness/types.d.ts.map +1 -0
  60. package/dist/eval/replay-harness/types.js +2 -0
  61. package/dist/eval/replay-harness/types.js.map +1 -0
  62. package/dist/server.d.ts.map +1 -1
  63. package/dist/server.js +1 -0
  64. package/dist/server.js.map +1 -1
  65. package/dist/tools/grant-app-access.js +1 -1
  66. package/dist/tools/grant-app-access.js.map +1 -1
  67. package/dist/tools/operate-pay.d.ts +10 -10
  68. package/dist/tools/operate-pay.d.ts.map +1 -1
  69. package/dist/tools/operate-pay.js +42 -16
  70. package/dist/tools/operate-pay.js.map +1 -1
  71. package/dist/tools/provision-drive.d.ts +165 -8
  72. package/dist/tools/provision-drive.d.ts.map +1 -1
  73. package/dist/tools/provision-drive.js +211 -40
  74. package/dist/tools/provision-drive.js.map +1 -1
  75. package/package.json +2 -1
@@ -109,6 +109,24 @@ function getChromium() {
109
109
  }
110
110
  return cachedChromium;
111
111
  }
112
+ export function hasPayPalHostedCheckoutFrame(frames) {
113
+ return frames.some((frame) => {
114
+ const marker = `${frame.url} ${frame.name} ${frame.title}`.toLowerCase();
115
+ if (/__zoid__paypal_(?:buttons|card_fields|checkout)/.test(marker))
116
+ return true;
117
+ try {
118
+ const url = new URL(frame.url);
119
+ const paypalHost = url.hostname === "paypal.com" ||
120
+ url.hostname.endsWith(".paypal.com") ||
121
+ url.hostname === "paypalobjects.com" ||
122
+ url.hostname.endsWith(".paypalobjects.com");
123
+ return paypalHost && /(?:smart|checkout|card[_ -]?fields|zoid)/.test(marker);
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ });
129
+ }
112
130
  const CURRENCY_SYMBOLS = {
113
131
  $: "USD",
114
132
  "€": "EUR",
@@ -494,7 +512,8 @@ async function withChromeStartupLock(fn) {
494
512
  }
495
513
  const selfManagedChromePids = new Set();
496
514
  let selfManagedCleanupInstalled = false;
497
- let orphanVerifyReapRan = false;
515
+ let orphanVerifierReapRan = false;
516
+ const orphanOperatorProfilesReaped = new Set();
498
517
  function killPid(pid, signal) {
499
518
  try {
500
519
  process.kill(pid, signal);
@@ -530,18 +549,44 @@ function registerSelfManagedChrome(child) {
530
549
  selfManagedChromePids.delete(child.pid);
531
550
  });
532
551
  }
533
- // Stale verifier browsers are the expensive leak mode: if the MCP process dies
552
+ // Stale verifier/operator browsers are the expensive leak mode: if the MCP process dies
534
553
  // mid-verify, self-launched Chrome survives as PPID=1 with a
535
- // ~/.trusty-squire/profiles/verify-* user-data-dir. It keeps the profile lock,
554
+ // ~/.trusty-squire/profiles/verify-* or ~/.trusty-squire/chrome-profile
555
+ // user-data-dir. It keeps the profile lock,
536
556
  // burns memory, and may leave defunct helper children. A live verifier should
537
557
  // never be parented to init, so these are safe to reap at the next browser
538
558
  // startup. Best-effort and Linux-only; failure must not block signups.
539
- function reapOrphanedVerifyBrowsersOnce() {
540
- if (orphanVerifyReapRan)
541
- return;
542
- orphanVerifyReapRan = true;
559
+ function escapeRegExp(value) {
560
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
561
+ }
562
+ export function matchesReapableBrowserArgs(args, profileDirs, includeVerifier) {
563
+ if (!/(?:chrome|chromium)/i.test(args))
564
+ return false;
565
+ const profiles = [...new Set(profileDirs)].map(escapeRegExp);
566
+ if (includeVerifier) {
567
+ profiles.unshift(String.raw `[^\s"']*\.trusty-squire\/profiles\/verify-[^\/\s"']+`);
568
+ }
569
+ if (profiles.length === 0)
570
+ return false;
571
+ const profile = `(?:${profiles.join("|")})`;
572
+ return new RegExp(`--user-data-dir=(?:"${profile}"|'${profile}'|${profile})(?=\\s|$)`).test(args);
573
+ }
574
+ export function claimOrphanBrowserReapScope(profileDir) {
575
+ const includeVerifier = !orphanVerifierReapRan;
576
+ const profileDirs = [...new Set([CHROME_PROFILE_DIR, profileDir])].filter((candidate) => !orphanOperatorProfilesReaped.has(candidate));
577
+ if (!includeVerifier && profileDirs.length === 0)
578
+ return null;
579
+ orphanVerifierReapRan = true;
580
+ for (const candidate of profileDirs)
581
+ orphanOperatorProfilesReaped.add(candidate);
582
+ return { includeVerifier, profileDirs };
583
+ }
584
+ function reapOrphanedBrowsersOnce(profileDir) {
543
585
  if (process.platform !== "linux")
544
586
  return;
587
+ const scope = claimOrphanBrowserReapScope(profileDir);
588
+ if (scope === null)
589
+ return;
545
590
  let rows = "";
546
591
  try {
547
592
  rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], {
@@ -561,14 +606,13 @@ function reapOrphanedVerifyBrowsersOnce() {
561
606
  const args = match[3] ?? "";
562
607
  if (Number.isFinite(pid) &&
563
608
  ppid === 1 &&
564
- /(?:chrome|chromium)/i.test(args) &&
565
- /--user-data-dir=.*\.trusty-squire\/profiles\/verify-[^/\s]+/.test(args)) {
609
+ matchesReapableBrowserArgs(args, scope.profileDirs, scope.includeVerifier)) {
566
610
  pids.push(pid);
567
611
  }
568
612
  }
569
613
  if (pids.length === 0)
570
614
  return;
571
- console.error(`[operator] reaping ${pids.length} orphaned verify Chrome process(es)`);
615
+ console.error(`[operator] reaping ${pids.length} orphaned Chrome process(es)`);
572
616
  for (const pid of pids)
573
617
  killPid(pid, "SIGTERM");
574
618
  setTimeout(() => {
@@ -805,6 +849,11 @@ export class BrowserController {
805
849
  // Google session across runs — see profile.ts / google-login.ts.
806
850
  context = null;
807
851
  page = null;
852
+ // The page start() configured with the controller's navigation/captcha
853
+ // handlers. OAuth may temporarily switch `this.page` to a popup, but warm
854
+ // reuse must always restore this original page rather than adopting a popup
855
+ // whose lifecycle handlers were never installed.
856
+ primaryPage = null;
808
857
  // Self-launch path (Turnstile-safe; see selfLaunchEnabled). When we spawn
809
858
  // Chrome ourselves and attach over CDP, these hold the child process and
810
859
  // the connected Browser so close() can tear both down.
@@ -865,6 +914,20 @@ export class BrowserController {
865
914
  // Per-launch egress override (verify-fleet identities each get their own IP).
866
915
  // null → use the env-global proxy. See resolveProxy().
867
916
  proxyOverride;
917
+ // Warm reuse is deliberately narrow: a browser belongs to exactly one
918
+ // persistent profile and one explicit egress override. Undefined/blank proxy
919
+ // values both mean "use the environment/default route".
920
+ matchesLaunchOptions(opts) {
921
+ const profileDir = opts.profileDir ?? CHROME_PROFILE_DIR;
922
+ const proxyUrl = opts.proxyUrl !== undefined && opts.proxyUrl.trim().length > 0 ? opts.proxyUrl.trim() : null;
923
+ return this.profileDir === profileDir && this.proxyOverride === proxyUrl;
924
+ }
925
+ // Required health gate for a warm browser. BrowserContext alone is not a
926
+ // sufficient signal: a dead CDP transport can leave stale JS objects behind.
927
+ isConnected() {
928
+ const browser = this.cdpBrowser ?? this.context?.browser() ?? null;
929
+ return browser?.isConnected() === true;
930
+ }
868
931
  // Which browser channel the most recent .start() actually used.
869
932
  // `null` means bundled Chromium; a string like "chrome" means a
870
933
  // real installed browser of that channel. Throws if .start() hasn't
@@ -1072,7 +1135,7 @@ export class BrowserController {
1072
1135
  console.error("[operator] resource blocking ON (image/media/font + analytics aborted; captcha/CSS/JS allowed)");
1073
1136
  }
1074
1137
  async start() {
1075
- reapOrphanedVerifyBrowsersOnce();
1138
+ reapOrphanedBrowsersOnce(this.profileDir);
1076
1139
  const channel = await detectChromiumChannel();
1077
1140
  this.launchedChannel = channel;
1078
1141
  const proxy = await this.resolveProxy();
@@ -1427,6 +1490,7 @@ export class BrowserController {
1427
1490
  if (!remoteMode)
1428
1491
  await context.addInitScript({ content: installWebglSpoofScript });
1429
1492
  this.page = context.pages()[0] ?? (await context.newPage());
1493
+ this.primaryPage = this.page;
1430
1494
  // addInitScript covers document-start page JS, but Playwright's
1431
1495
  // page.evaluate utility execution can run in a separate realm. Install the
1432
1496
  // same no-op helper there with a STRING evaluate (tsx cannot wrap strings
@@ -3301,6 +3365,13 @@ export class BrowserController {
3301
3365
  }
3302
3366
  }
3303
3367
  if (tagName === "select") {
3368
+ // Keep the resolved target as a Locator. Walker selectors can include
3369
+ // Playwright chains such as `select.foo >> nth=1`; appending CSS text to
3370
+ // those strings changes the chain's meaning (`... >> nth=1 option`) and
3371
+ // makes a full select appear option-less. Descendant lookup, selection,
3372
+ // and verification must all stay anchored to the same resolved element.
3373
+ const selectLocator = this.page.locator(activeSelector).first();
3374
+ const optionLocator = selectLocator.locator("option");
3304
3375
  // Native path. rc.15 — keep value="" options selectable. The
3305
3376
  // Railway workspace dropdown's "No workspace" option is value=""
3306
3377
  // and it IS the right pick for an account-scoped token. The
@@ -3309,9 +3380,7 @@ export class BrowserController {
3309
3380
  // never reach that option. Now: fallback list keeps every option
3310
3381
  // (with the first option's value, even if empty), and a matched
3311
3382
  // text-based pick is honored verbatim — including empty values.
3312
- const allValues = await this.page
3313
- .locator(`${activeSelector} option`)
3314
- .evaluateAll((opts) => opts.map((o) => (o instanceof HTMLOptionElement ? o.value : "")));
3383
+ const allValues = await optionLocator.evaluateAll((opts) => opts.map((o) => (o instanceof HTMLOptionElement ? o.value : "")));
3315
3384
  if (allValues.length === 0) {
3316
3385
  throw new Error(`<select> ${activeSelector} has no selectable option`);
3317
3386
  }
@@ -3325,9 +3394,7 @@ export class BrowserController {
3325
3394
  // Returns either a matched value (may be "") or null when no
3326
3395
  // option's text matches. Wrap in an object so we can
3327
3396
  // distinguish "matched to empty value" from "no match".
3328
- const matched = await this.page
3329
- .locator(`${activeSelector} option`)
3330
- .evaluateAll((opts, needle) => {
3397
+ const matched = await optionLocator.evaluateAll((opts, needle) => {
3331
3398
  const hit = opts
3332
3399
  .filter((o) => o instanceof HTMLOptionElement)
3333
3400
  .find((o) => o.textContent?.toLowerCase().includes(needle));
@@ -3341,8 +3408,8 @@ export class BrowserController {
3341
3408
  if (chosenValue === undefined) {
3342
3409
  throw new Error(`<select> ${activeSelector} has no selectable option`);
3343
3410
  }
3344
- await this.page.selectOption(activeSelector, chosenValue);
3345
- const committedValue = await this.page.locator(activeSelector).first().inputValue();
3411
+ await selectLocator.selectOption(chosenValue);
3412
+ const committedValue = await selectLocator.inputValue();
3346
3413
  if (committedValue !== chosenValue) {
3347
3414
  throw new Error(`<select> ${activeSelector}: selected value ${JSON.stringify(chosenValue)} did not stick`);
3348
3415
  }
@@ -3352,19 +3419,19 @@ export class BrowserController {
3352
3419
  // "No workspace") keeps tripping the warning every round, and
3353
3420
  // the planner gets stuck in a select→select→… loop trying to
3354
3421
  // satisfy a warning the form has already satisfied.
3355
- await this.page
3356
- .locator(activeSelector)
3357
- .first()
3422
+ await selectLocator
3358
3423
  .evaluate((el) => {
3359
3424
  if (el instanceof HTMLElement)
3360
3425
  el.setAttribute("data-ts-touched", "1");
3361
3426
  })
3362
3427
  .catch(() => { });
3363
- return;
3428
+ return await selectLocator.evaluate((select) => select instanceof HTMLSelectElement
3429
+ ? (select.selectedOptions[0]?.textContent ?? "").replace(/\s+/g, " ").trim()
3430
+ : "");
3364
3431
  }
3365
3432
  // Custom combobox path. Sentry, Radix, Headless UI, React Aria
3366
3433
  // — every modern React picker emits role=option on its items.
3367
- await this.selectFromCombobox(activeSelector, optionMatcher);
3434
+ return await this.selectFromCombobox(activeSelector, optionMatcher);
3368
3435
  }
3369
3436
  // Set the country on a phone-number field backed by a phone-local native
3370
3437
  // <select>, including react-phone-number-input's opacity:0 country select.
@@ -3380,11 +3447,50 @@ export class BrowserController {
3380
3447
  throw new Error("setPhoneCountry: empty country argument");
3381
3448
  }
3382
3449
  await this.clearPhoneCountryMarkers();
3450
+ await this.page
3451
+ .locator('[data-ts-phone-country-control="1"]')
3452
+ .evaluateAll((elements) => {
3453
+ elements.forEach((element) => element.removeAttribute("data-ts-phone-country-control"));
3454
+ })
3455
+ .catch(() => undefined);
3383
3456
  if (await this.trySetPhoneCountryNativeSelect(query))
3384
3457
  return;
3385
3458
  throw new Error("set_phone_country: no supported native phone-country <select> found " +
3386
3459
  "(this widget family is not supported yet) — enter a valid contact number instead.");
3387
3460
  }
3461
+ async verifyPhoneCountry(country) {
3462
+ if (!this.page)
3463
+ return false;
3464
+ const query = classifyPhoneCountryQuery(country);
3465
+ if (query.dialCode === undefined && query.iso2 === undefined && query.name === undefined) {
3466
+ return false;
3467
+ }
3468
+ const selected = await this.page.evaluate(() => {
3469
+ const control = document.querySelector('select[data-ts-phone-country-control="1"]');
3470
+ if (!(control instanceof HTMLSelectElement))
3471
+ return null;
3472
+ const option = control.selectedOptions[0];
3473
+ return option === undefined
3474
+ ? null
3475
+ : {
3476
+ value: option.value,
3477
+ text: (option.textContent ?? "").replace(/\s+/g, " ").trim(),
3478
+ };
3479
+ });
3480
+ if (selected === null)
3481
+ return false;
3482
+ const option = {
3483
+ text: selected.text.length > 0 ? selected.text : undefined,
3484
+ iso2: /^[A-Za-z]{2}$/.test(selected.value) ? selected.value.toUpperCase() : undefined,
3485
+ dialCode: /^\+?\d{1,4}$/.test(selected.value) ? selected.value.replace(/\D/g, "") : undefined,
3486
+ };
3487
+ return phoneCountryOptionMatches(query, option);
3488
+ }
3489
+ async hasPhoneCountryControl() {
3490
+ if (!this.page)
3491
+ return false;
3492
+ return (await this.page.locator('select[data-ts-phone-country-control="1"]').count()) === 1;
3493
+ }
3388
3494
  // Strategy 1 — a native <select> that governs the phone country (react-
3389
3495
  // phone-number-input's `opacity:0` PhoneInputCountrySelect, or any bespoke
3390
3496
  // widget backed by a real <select>). Detection is deliberately conservative
@@ -3487,6 +3593,7 @@ export class BrowserController {
3487
3593
  sel.value = val;
3488
3594
  sel.dispatchEvent(new Event("input", { bubbles: true }));
3489
3595
  sel.dispatchEvent(new Event("change", { bubbles: true }));
3596
+ sel.setAttribute("data-ts-phone-country-control", "1");
3490
3597
  return true;
3491
3598
  }, { marker: best.marker, val: value });
3492
3599
  const committedValue = assigned
@@ -3651,7 +3758,9 @@ export class BrowserController {
3651
3758
  else if ((await options.count()) === 0) {
3652
3759
  throw new Error(`combobox ${triggerSelector}: opened popup has no actionable options`);
3653
3760
  }
3761
+ const committedText = (await target.innerText()).replace(/\s+/g, " ").trim();
3654
3762
  await this.clickComboboxOption(target);
3763
+ return committedText;
3655
3764
  }
3656
3765
  finally {
3657
3766
  await this.clearComboboxMarkers();
@@ -5104,6 +5213,21 @@ export class BrowserController {
5104
5213
  ...amount,
5105
5214
  };
5106
5215
  }
5216
+ async isPayPalHostedCheckout() {
5217
+ if (!this.page)
5218
+ throw new Error("Browser not started");
5219
+ const iframeDescriptors = await this.page.evaluate(() => Array.from(document.querySelectorAll("iframe"), (iframe) => ({
5220
+ url: iframe.src,
5221
+ name: iframe.name,
5222
+ title: iframe.title,
5223
+ })));
5224
+ const frameDescriptors = this.page.frames().map((frame) => ({
5225
+ url: frame.url(),
5226
+ name: frame.name(),
5227
+ title: "",
5228
+ }));
5229
+ return hasPayPalHostedCheckoutFrame([...iframeDescriptors, ...frameDescriptors]);
5230
+ }
5107
5231
  // Common autocomplete/name selectors across all CDP-reachable frames,
5108
5232
  // including cross-origin hosted fields. No PSP-specific adapters.
5109
5233
  async fillAndSubmitCheckout(card) {
@@ -5111,7 +5235,7 @@ export class BrowserController {
5111
5235
  throw new Error("Browser not started");
5112
5236
  const frames = this.page.frames();
5113
5237
  const filled = new Set();
5114
- const fillFirst = async (field, value, selectors) => {
5238
+ const fillFirst = async (field, value, selectors, typePerKey = false) => {
5115
5239
  if (value === undefined || value.length === 0)
5116
5240
  return false;
5117
5241
  for (const frame of frames) {
@@ -5137,6 +5261,10 @@ export class BrowserController {
5137
5261
  if (!selected)
5138
5262
  continue;
5139
5263
  }
5264
+ else if (typePerKey) {
5265
+ await input.fill("");
5266
+ await input.pressSequentially(value, { delay: this.humanize ? rand(40, 110) : 0 });
5267
+ }
5140
5268
  else {
5141
5269
  await input.fill(value);
5142
5270
  }
@@ -5146,13 +5274,38 @@ export class BrowserController {
5146
5274
  }
5147
5275
  return false;
5148
5276
  };
5277
+ const hasFillable = async (selectors) => {
5278
+ for (const frame of frames) {
5279
+ const matches = frame.locator(selectors);
5280
+ const count = Math.min(await matches.count().catch(() => 0), 10);
5281
+ for (let i = 0; i < count; i += 1) {
5282
+ const input = matches.nth(i);
5283
+ if (!(await input.isVisible().catch(() => false)))
5284
+ continue;
5285
+ if (await input.isEnabled().catch(() => false))
5286
+ return true;
5287
+ }
5288
+ }
5289
+ return false;
5290
+ };
5149
5291
  try {
5150
5292
  await fillFirst("pan", card.pan, 'input[autocomplete~="cc-number"],input[name*="cardnumber" i],input[id*="card-number" i],input[id*="cardnumber" i]');
5151
- const combinedExpiry = `${card.exp_month.padStart(2, "0")}/${card.exp_year.slice(-2)}`;
5152
- const combined = await fillFirst("expiry", combinedExpiry, 'input[autocomplete~="cc-exp"],input[name*="expir" i]:not([name*="month" i]):not([name*="year" i]),input[name="exp" i],input[name*="exp-date" i],input[id*="expir" i]:not([id*="month" i]):not([id*="year" i]),input[id="exp" i],input[id*="exp-date" i]');
5153
- if (!combined) {
5154
- await fillFirst("exp_month", card.exp_month.padStart(2, "0"), '[autocomplete~="cc-exp-month"],[name*="exp_month" i],[name*="expmonth" i]');
5155
- await fillFirst("exp_year", card.exp_year, '[autocomplete~="cc-exp-year"],[name*="exp_year" i],[name*="expyear" i]');
5293
+ const expiryMonthSelectors = '[autocomplete~="cc-exp-month"],[name*="exp_month" i],[name*="expmonth" i],[name*="exp" i][name*="month" i],[id*="exp" i][id*="month" i]';
5294
+ const expiryYearSelectors = '[autocomplete~="cc-exp-year"],[name*="exp_year" i],[name*="expyear" i],[name*="exp" i][name*="year" i],[id*="exp" i][id*="year" i]';
5295
+ const hasSplitExpiry = (await hasFillable(expiryMonthSelectors)) && (await hasFillable(expiryYearSelectors));
5296
+ if (hasSplitExpiry) {
5297
+ await fillFirst("exp_month", card.exp_month.padStart(2, "0"), expiryMonthSelectors);
5298
+ await fillFirst("exp_year", card.exp_year, expiryYearSelectors);
5299
+ }
5300
+ else {
5301
+ // A combined expiry field's formatter owns the slash. Send only the four
5302
+ // digits as real key events so numeric-only fields accept them and the
5303
+ // site's key/input handlers can turn e.g. "1230" into "12/30".
5304
+ const combined = await fillFirst("expiry", `${card.exp_month.padStart(2, "0")}${card.exp_year.slice(-2)}`, 'input[autocomplete~="cc-exp"],input[name*="expir" i]:not([name*="month" i]):not([name*="year" i]),input[name="exp" i],input[name*="exp-date" i],input[id*="expir" i]:not([id*="month" i]):not([id*="year" i]),input[id="exp" i],input[id*="exp-date" i],input[placeholder="MM/YY" i],input[placeholder="MM / YY" i],input[aria-label="MM/YY" i],input[aria-label="MM / YY" i],label:has-text("MM/YY") input,label:has-text("MM / YY") input', true);
5305
+ if (!combined) {
5306
+ await fillFirst("exp_month", card.exp_month.padStart(2, "0"), expiryMonthSelectors);
5307
+ await fillFirst("exp_year", card.exp_year, expiryYearSelectors);
5308
+ }
5156
5309
  }
5157
5310
  const fields = [
5158
5311
  [
@@ -8266,6 +8419,47 @@ export class BrowserController {
8266
8419
  // best-effort
8267
8420
  }
8268
8421
  }
8422
+ // End one sequential task without tearing down Chrome. The original,
8423
+ // handler-bound page survives; OAuth popups and any other incidental tabs are
8424
+ // closed. about:blank replaces the task document while the persistent profile
8425
+ // (cookies, OAuth identity, local profile data) intentionally remains intact.
8426
+ async resetPageForReuse() {
8427
+ const context = this.context;
8428
+ const primaryPage = this.primaryPage;
8429
+ if (!this.isConnected() || context === null || primaryPage === null || primaryPage.isClosed()) {
8430
+ throw new Error("warm browser page is not reusable");
8431
+ }
8432
+ this.page = primaryPage;
8433
+ this.oauthProductPage = null;
8434
+ this.oauthNetLog = [];
8435
+ this.mouseX = 100;
8436
+ this.mouseY = 100;
8437
+ for (const page of context.pages()) {
8438
+ if (page !== primaryPage && !page.isClosed()) {
8439
+ let timer;
8440
+ try {
8441
+ await Promise.race([
8442
+ page.close(),
8443
+ new Promise((_, reject) => {
8444
+ timer = setTimeout(() => reject(new Error("timed out closing warm browser popup")), 5_000);
8445
+ timer.unref();
8446
+ }),
8447
+ ]);
8448
+ }
8449
+ finally {
8450
+ if (timer !== undefined)
8451
+ clearTimeout(timer);
8452
+ }
8453
+ }
8454
+ }
8455
+ if (context.pages().some((page) => page !== primaryPage && !page.isClosed())) {
8456
+ throw new Error("warm browser reset left a non-primary page open");
8457
+ }
8458
+ await primaryPage.goto("about:blank", {
8459
+ waitUntil: "domcontentloaded",
8460
+ timeout: 30_000,
8461
+ });
8462
+ }
8269
8463
  async close() {
8270
8464
  // Each step is best-effort and independent: a throw closing the page
8271
8465
  // or context must NOT skip the Xvfb teardown below, or the virtual
@@ -8286,6 +8480,10 @@ export class BrowserController {
8286
8480
  ]);
8287
8481
  if (this.page)
8288
8482
  await capped(this.page.close(), 5_000);
8483
+ this.page = null;
8484
+ this.primaryPage = null;
8485
+ this.oauthProductPage = null;
8486
+ this.oauthNetLog = [];
8289
8487
  if (this.context)
8290
8488
  await capped(this.context.close(), 10_000);
8291
8489
  // Self-launch path: disconnect the CDP browser and SIGKILL the Chrome we