@trusty-squire/mcp 0.5.7 → 0.6.0

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 (46) hide show
  1. package/README.md +128 -68
  2. package/dist/api-client.d.ts +1 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +27 -0
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/bin.js +14 -1
  7. package/dist/bin.js.map +1 -1
  8. package/dist/bot/agent.d.ts +11 -0
  9. package/dist/bot/agent.d.ts.map +1 -1
  10. package/dist/bot/agent.js +566 -49
  11. package/dist/bot/agent.js.map +1 -1
  12. package/dist/bot/browser.d.ts +11 -1
  13. package/dist/bot/browser.d.ts.map +1 -1
  14. package/dist/bot/browser.js +365 -20
  15. package/dist/bot/browser.js.map +1 -1
  16. package/dist/bot/debug.d.ts.map +1 -1
  17. package/dist/bot/debug.js +19 -8
  18. package/dist/bot/debug.js.map +1 -1
  19. package/dist/bot/google-login.d.ts +4 -0
  20. package/dist/bot/google-login.d.ts.map +1 -1
  21. package/dist/bot/google-login.js +129 -14
  22. package/dist/bot/google-login.js.map +1 -1
  23. package/dist/bot/index.d.ts +3 -0
  24. package/dist/bot/index.d.ts.map +1 -1
  25. package/dist/bot/index.js +3 -0
  26. package/dist/bot/index.js.map +1 -1
  27. package/dist/bot/xvfb.d.ts +10 -0
  28. package/dist/bot/xvfb.d.ts.map +1 -0
  29. package/dist/bot/xvfb.js +75 -0
  30. package/dist/bot/xvfb.js.map +1 -0
  31. package/dist/install/agents.d.ts.map +1 -1
  32. package/dist/install/agents.js +37 -4
  33. package/dist/install/agents.js.map +1 -1
  34. package/dist/install/cli.d.ts +1 -0
  35. package/dist/install/cli.d.ts.map +1 -1
  36. package/dist/install/cli.js +194 -89
  37. package/dist/install/cli.js.map +1 -1
  38. package/dist/install/ui.d.ts +23 -0
  39. package/dist/install/ui.d.ts.map +1 -0
  40. package/dist/install/ui.js +108 -0
  41. package/dist/install/ui.js.map +1 -0
  42. package/dist/tools/provision-any.d.ts +23 -0
  43. package/dist/tools/provision-any.d.ts.map +1 -1
  44. package/dist/tools/provision-any.js +135 -9
  45. package/dist/tools/provision-any.js.map +1 -1
  46. package/package.json +4 -1
package/dist/bot/agent.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // prompt rather than threading service-specific logic through the agent.
10
10
  import { rankAndCapInventory, scoreSignupButton } from "./browser.js";
11
11
  import { OAUTH_PROVIDERS, extractOAuthScopes, } from "./oauth-providers.js";
12
+ import { extractGoogleNumberMatch, scrapeGoogleScopePhrases } from "./google-login.js";
12
13
  import { loggedInProviders } from "./login-state.js";
13
14
  import { saveDebugSnapshot } from "./debug.js";
14
15
  import { captureOnboardingRound } from "./onboarding-capture.js";
@@ -84,6 +85,61 @@ export class LLMCallBudgetExceeded extends Error {
84
85
  this.name = "LLMCallBudgetExceeded";
85
86
  }
86
87
  }
88
+ // Best-effort canonical signup URL for a service when the caller
89
+ // didn't pass one. Most dev-SaaS targets (Resend, Postmark, Mailgun,
90
+ // MailerSend, IPInfo, Stripe, PostHog) live at <name>.com/signup —
91
+ // the .com default catches them. The exceptions — services on .io,
92
+ // .ai, .dev — live in KNOWN_DOMAINS so a Sentry signup doesn't waste
93
+ // the long Google-search fallback path looking for sentry.com (which
94
+ // redirects weirdly to sentry.io and breaks looksLikeSignupPage).
95
+ // Anything still wrong falls through to the search-and-find path.
96
+ // Exported for unit testing.
97
+ // Either a hostname (default path: /signup) or a full URL (when the
98
+ // service's signup lives on a subdomain or uses a non-standard path —
99
+ // e.g. Cloudflare's dash.cloudflare.com/sign-up).
100
+ const KNOWN_DOMAINS = {
101
+ sentry: "sentry.io",
102
+ openrouter: "openrouter.ai",
103
+ mistral: "mistral.ai",
104
+ anthropic: "anthropic.com",
105
+ mailtrap: "mailtrap.io",
106
+ axiom: "axiom.co",
107
+ loops: "loops.so",
108
+ e2b: "e2b.dev",
109
+ railway: "railway.app",
110
+ supabase: "supabase.com",
111
+ replicate: "replicate.com",
112
+ modal: "modal.com",
113
+ // PostHog uses posthog.com but the dashboard lives at us.posthog.com /
114
+ // eu.posthog.com — signup is on the marketing site, .com is right.
115
+ posthog: "posthog.com",
116
+ // Cloudflare's marketing site has no signup form — it CTAs into the
117
+ // dashboard. Skip the redirect chase and land on the real form.
118
+ cloudflare: "https://dash.cloudflare.com/sign-up",
119
+ // Vercel: marketing /signup redirects through OAuth provider tiles
120
+ // but the actual email form sits on the dashboard.
121
+ vercel: "https://vercel.com/signup",
122
+ };
123
+ export function guessSignupUrl(service) {
124
+ const slug = service.toLowerCase().replace(/[^a-z0-9]/g, "");
125
+ const entry = KNOWN_DOMAINS[slug];
126
+ if (entry !== undefined && /^https?:\/\//i.test(entry))
127
+ return entry;
128
+ const host = entry ?? `${slug}.com`;
129
+ return `https://${host}/signup`;
130
+ }
131
+ // True when the URL is a Google search results page — used to gate
132
+ // the prewarm + the post-load "did we land somewhere useful?" check.
133
+ export function isGoogleSearchUrl(url) {
134
+ try {
135
+ const u = new URL(url);
136
+ return ((u.hostname === "www.google.com" || u.hostname === "google.com") &&
137
+ u.pathname.startsWith("/search"));
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
87
143
  // The set of value_kinds the planner is allowed to emit. Kept as a
88
144
  // runtime array so validation and the exhaustive `valueFor` switch
89
145
  // share one source of truth.
@@ -259,6 +315,26 @@ export function formatInventory(inventory) {
259
315
  })
260
316
  .join("\n");
261
317
  }
318
+ // Recognize a full-page anti-bot interstitial that's still up. Returns
319
+ // the vendor name (for the status message) or null. Pattern matching
320
+ // on visible text rather than markers — most vendors use the same UX
321
+ // template, and matching the user-visible copy is robust to the actual
322
+ // implementation underneath. Exported for unit testing.
323
+ export function detectAntiBotBlock(html) {
324
+ const text = html.toLowerCase();
325
+ // Cloudflare "Just a moment..." / Turnstile pre-clear page. Strong
326
+ // signal: the literal text + the cf-* class names + the title.
327
+ if (/just a moment|cf-(challenge|browser-verification|turnstile)|performing security verification/i.test(text)) {
328
+ return "Cloudflare";
329
+ }
330
+ if (/sucuri|sucuri website firewall/i.test(text))
331
+ return "Sucuri";
332
+ if (/datadome|dd-captcha/i.test(text))
333
+ return "DataDome";
334
+ if (/incapsula|imperva/i.test(text))
335
+ return "Imperva";
336
+ return null;
337
+ }
262
338
  // True when the page has no fillable text input AND no button that
263
339
  // reads as an email-signup option — a genuinely OAuth/SSO-only
264
340
  // service with no form to automate (F3 Issue 4).
@@ -378,7 +454,18 @@ export function parsePostVerifyStep(raw, allowedSelectors) {
378
454
  case "select": {
379
455
  const selector = requireString(obj, "selector", "post-verify select step");
380
456
  checkSelector(selector, "post-verify select step");
381
- return { kind: "select", selector, reason };
457
+ // F11: `option_text` is optional — when present, the executor
458
+ // picks the option whose visible text contains it (case-
459
+ // insensitive substring). When absent, picks the first option.
460
+ const optionText = obj["option_text"];
461
+ return {
462
+ kind: "select",
463
+ selector,
464
+ reason,
465
+ ...(typeof optionText === "string" && optionText.length > 0
466
+ ? { option_text: optionText }
467
+ : {}),
468
+ };
382
469
  }
383
470
  case "check": {
384
471
  const selector = requireString(obj, "selector", "post-verify check step");
@@ -453,6 +540,25 @@ const EMBEDDED_KEY_PREFIXES = [
453
540
  //
454
541
  // Exported for unit testing — the regex tuning here is the load-
455
542
  // bearing logic and deserves direct coverage.
543
+ // True when `capturedKey` is followed by a truncation marker (`...`
544
+ // or the Unicode ellipsis `…`) in `sourceText`. That marker is the
545
+ // signal that the visible display masked the full secret — the
546
+ // regex captured everything up to but not including the marker, so
547
+ // the value LOOKS valid but is short. Used by F10's
548
+ // extract-via-Copy-button recovery path; without this check, the
549
+ // bot accepts the truncated value, stores it, and the user discovers
550
+ // the failure only when their next API call returns 401.
551
+ export function isTruncatedCapture(sourceText, capturedKey) {
552
+ const idx = sourceText.indexOf(capturedKey);
553
+ if (idx < 0)
554
+ return false;
555
+ const after = sourceText.slice(idx + capturedKey.length, idx + capturedKey.length + 10);
556
+ // Whitespace OK between key and ellipsis (some modals render as
557
+ // "sk-or-v1-xxxx ..."). Three OR MORE dots; two dots are ordinary
558
+ // punctuation and would false-positive on e.g. "key value.." in
559
+ // help text.
560
+ return /^\s*(?:\.{3,}|…)/.test(after);
561
+ }
456
562
  export function extractApiKeyFromText(text) {
457
563
  const prefixed = [
458
564
  /\bre_[a-zA-Z0-9_]{20,}\b/, // Resend (key body contains underscores)
@@ -468,6 +574,15 @@ export function extractApiKeyFromText(text) {
468
574
  /\bSG\.[a-zA-Z0-9_\-]{20,}\.[a-zA-Z0-9_\-]{20,}\b/, // SendGrid
469
575
  /\brnd_[a-zA-Z0-9]{20,}\b/, // Render
470
576
  /\bsntry[su]_[A-Za-z0-9_=\-]{20,}/, // Sentry org/user auth token
577
+ // OpenRouter, Anthropic, OpenAI — these are the dominant
578
+ // OAuth-completed-then-copy-needed services. Specific-prefix
579
+ // patterns first so a labeled-pattern fallback isn't load-
580
+ // bearing for them. Putting `sk-or-v1-` before `sk-` so it wins
581
+ // when both could match (cosmetic; both capture the same value).
582
+ /\bsk-or-v1-[a-zA-Z0-9_-]{20,}/, // OpenRouter (sk-or-v1-…)
583
+ /\bsk-ant-[a-zA-Z0-9_-]{20,}/, // Anthropic (sk-ant-…)
584
+ /\bsk-proj-[a-zA-Z0-9_-]{20,}/, // OpenAI project key
585
+ /\bsk-[a-zA-Z0-9]{40,}/, // OpenAI legacy (`sk-` + ~48 chars, no dashes)
471
586
  ];
472
587
  for (const pattern of prefixed) {
473
588
  const match = text.match(pattern);
@@ -700,6 +815,21 @@ export class SignupAgent {
700
815
  steps.push("OAuth-first: no usable provider affordance on the page — " +
701
816
  "falling back to form-fill");
702
817
  }
818
+ // Anti-bot interstitial that didn't clear (Cloudflare/Sucuri/
819
+ // DataDome "Just a moment..." pages that BrowserController has
820
+ // already attempted to wait + reload through). Detect by page
821
+ // text — the inventory will be tiny because the interstitial
822
+ // intentionally has 0 interactive elements. Surface as its own
823
+ // status, not as oauth_required: the latter implies "service is
824
+ // OAuth-only", which is wrong for Cloudflare et al.
825
+ if (inventory.length < 5) {
826
+ const block = detectAntiBotBlock(state.html);
827
+ if (block !== null) {
828
+ steps.push(`Anti-bot block: ${block} interstitial would not clear after retries — ` +
829
+ `the bot's fingerprint/IP did not pass ${block}'s server-side risk score`);
830
+ return { kind: "anti_bot_blocked", vendor: block };
831
+ }
832
+ }
703
833
  // OAuth-only: no fillable input AND no button that reads as an
704
834
  // email-signup option — nothing to automate (Issue 4).
705
835
  if (isOauthOnlyChooser(inventory)) {
@@ -851,6 +981,30 @@ export class SignupAgent {
851
981
  const { inventory, buttonsDropped } = rankAndCapInventory(raw, buttonCap, oauthProviders);
852
982
  steps.push(`Inventory: ${inventory.length} element(s)` +
853
983
  (buttonsDropped > 0 ? ` (${buttonsDropped} low-ranked button(s) dropped)` : ""));
984
+ // Diagnostic: a suspiciously tiny inventory usually means the page
985
+ // either didn't finish rendering OR an anti-bot interstitial (CF
986
+ // Turnstile, "Just a moment...", reCAPTCHA wall) is up. Surface the
987
+ // page state into the step trail so the failure is debuggable from
988
+ // outside the bot host.
989
+ if (inventory.length < 5 && raw.length < 5) {
990
+ try {
991
+ const state = await this.browser.getState();
992
+ const text = state.html
993
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
994
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
995
+ .replace(/<[^>]+>/g, " ")
996
+ .replace(/\s+/g, " ")
997
+ .trim()
998
+ .slice(0, 240);
999
+ const antiBot = /just a moment|verify you are human|attention required|cloudflare|cf-challenge|cf-turnstile|recaptcha|are you a robot/i.test(state.html);
1000
+ steps.push(`Inventory diagnostic: title=${JSON.stringify(state.title.slice(0, 80))} ` +
1001
+ `url=${state.url.slice(0, 120)} text=${JSON.stringify(text)}` +
1002
+ (antiBot ? " ⚠ anti-bot interstitial detected" : ""));
1003
+ }
1004
+ catch {
1005
+ // best-effort diagnostic; never abort on its failure
1006
+ }
1007
+ }
854
1008
  return inventory;
855
1009
  }
856
1010
  // Which OAuth providers may this signup take? An explicit
@@ -1037,7 +1191,11 @@ export class SignupAgent {
1037
1191
  // call hung. Override the 10-minute default with
1038
1192
  // UNIVERSAL_BOT_RUN_TIMEOUT_MS.
1039
1193
  async signup(task) {
1040
- const steps = [];
1194
+ // task.stepsSink lets a caller (provision-any) share the live step
1195
+ // trail so check_provision_status can surface mid-run prompts
1196
+ // (Google number-match etc.). Without it, the run still works —
1197
+ // steps are just only visible in the final result.
1198
+ const steps = task.stepsSink ?? [];
1041
1199
  const rawTimeout = Number(process.env.UNIVERSAL_BOT_RUN_TIMEOUT_MS);
1042
1200
  const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 600_000;
1043
1201
  let timer;
@@ -1070,9 +1228,30 @@ export class SignupAgent {
1070
1228
  const password = task.generatePassword();
1071
1229
  const displayName = "Trusty Squire Bot";
1072
1230
  const username = `tsbot${Date.now().toString().slice(-7)}`;
1231
+ // F13 diagnostic: which Chrome launch mode start() chose, and
1232
+ // whether egress went through the configured proxy. Lets us tell
1233
+ // from outside the box whether the bot actually got an X display
1234
+ // surface AND whether the residential-proxy path engaged.
1235
+ steps.push(`Browser: launched mode=${this.browser.launchMode} ` +
1236
+ `proxy=${this.browser.proxied ?? "direct"} ` +
1237
+ `channel=${this.browser.channel ?? "bundled-chromium"}`);
1073
1238
  try {
1074
1239
  // Step 1: Navigate to signup page
1075
- const signupUrl = task.signupUrl ?? `https://www.google.com/search?q=${encodeURIComponent(`${task.service} signup`)}`;
1240
+ //
1241
+ // When no signup_url is provided, GUESS the canonical
1242
+ // `https://<service>.com/signup` first — most dev-SaaS targets
1243
+ // (Resend, Postmark, Mailgun, IPInfo, MailerSend, ...) live
1244
+ // there. Falls back to a Google search + findSignupLink only if
1245
+ // the guess doesn't look like a usable signup page after load
1246
+ // (404, marketing page with no inputs/OAuth, etc.). The old
1247
+ // default ALWAYS started on Google search, which on top of
1248
+ // being slower had its own failure mode: the search-result
1249
+ // extractor often returned a docs URL or marketing root rather
1250
+ // than the signup page, and the bot would bail with
1251
+ // oauth_required when it landed on a page that didn't show the
1252
+ // OAuth buttons until you clicked "Sign up" first.
1253
+ const guessed = task.signupUrl ?? guessSignupUrl(task.service);
1254
+ let signupUrl = guessed;
1076
1255
  // Prewarm the target origin before hitting the (often-strict) signup
1077
1256
  // page. Two things this buys us:
1078
1257
  // 1. First-party cookies on the root domain. Cloudflare's
@@ -1085,26 +1264,30 @@ export class SignupAgent {
1085
1264
  // Turnstile that scores the whole session, not just the
1086
1265
  // submit moment.
1087
1266
  //
1088
- // Mode selection: the heavy "referrer-chain" prewarm is what
1089
- // actually moves the v3 score (~30-45s of wall clock; google
1090
- // search → click → scroll → navigate). The light "fast" mode
1091
- // is dwell-only (~2s). We use the cache to decide: cold cache
1092
- // means do the heavy one and cache the result; warm cache means
1093
- // we've recently established cookies for this domain and the
1094
- // light version is enough.
1095
- //
1096
- // Skip entirely when the URL is a Google-search fallback (no
1097
- // real origin to warm) or when prewarm itself fails (don't fail
1098
- // the run just because the marketing site is down).
1099
- if (task.signupUrl !== undefined) {
1267
+ // The prewarm runs against the guessed (or explicit) origin
1268
+ // skipped only when the URL is a Google-search URL itself.
1269
+ if (!isGoogleSearchUrl(signupUrl)) {
1100
1270
  await this.runPrewarm(signupUrl, steps);
1101
1271
  }
1102
1272
  steps.push(`Navigating to ${signupUrl}`);
1103
1273
  await this.browser.goto(signupUrl);
1104
1274
  await this.browser.wait(2);
1105
- if (task.signupUrl === undefined) {
1275
+ // When we *guessed* (no signup_url provided) and the page after
1276
+ // load doesn't look like a signup page — no inputs, no OAuth
1277
+ // affordance, or an obvious 404/error title — fall back to the
1278
+ // search-and-find-link path. This is the safety net that lets
1279
+ // the bot recover from a wrong canonical guess (e.g. a service
1280
+ // that uses /register or a non-`.com` TLD).
1281
+ if (task.signupUrl === undefined && !(await this.looksLikeSignupPage())) {
1282
+ steps.push(`${guessed} didn't look like a signup page — searching for the real one`);
1283
+ const fallbackSearch = `https://www.google.com/search?q=${encodeURIComponent(`${task.service} signup`)}`;
1284
+ await this.browser.goto(fallbackSearch);
1285
+ await this.browser.wait(2);
1286
+ signupUrl = fallbackSearch;
1287
+ }
1288
+ if (signupUrl !== guessed || isGoogleSearchUrl(signupUrl)) {
1106
1289
  steps.push("Searching for signup page...");
1107
- const found = await this.findSignupLink();
1290
+ const found = await this.findSignupLink(task.service);
1108
1291
  if (found !== null) {
1109
1292
  // Now that we know the real signup origin, prewarm it before
1110
1293
  // the deep navigation. Same rationale as above.
@@ -1157,6 +1340,16 @@ export class SignupAgent {
1157
1340
  steps,
1158
1341
  ...this.resultTail(),
1159
1342
  };
1343
+ case "anti_bot_blocked":
1344
+ return {
1345
+ success: false,
1346
+ error: `anti_bot_blocked: ${task.service}'s ${outcome.vendor} anti-bot interstitial would ` +
1347
+ `not clear — the bot's IP/fingerprint did not pass ${outcome.vendor}'s server-side ` +
1348
+ `risk score. This is a soft block (no challenge to solve); the user should sign up ` +
1349
+ `manually.`,
1350
+ steps,
1351
+ ...this.resultTail(),
1352
+ };
1160
1353
  case "oauth":
1161
1354
  // T6/T7 — OAuth-first path. runOAuthFlow drives the consent
1162
1355
  // handshake and post-OAuth onboarding to its own terminal
@@ -1225,6 +1418,7 @@ export class SignupAgent {
1225
1418
  credentials: { email: task.email, password },
1226
1419
  maxRounds,
1227
1420
  steps,
1421
+ ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
1228
1422
  });
1229
1423
  }
1230
1424
  }
@@ -1300,6 +1494,12 @@ export class SignupAgent {
1300
1494
  // Bounded consent walk — handles account-chooser → consent as two
1301
1495
  // steps without ever spinning. Each iteration re-reads the page.
1302
1496
  const MAX_OAUTH_NAV = 6;
1497
+ // True once a clean scope-grant consent has already been
1498
+ // auto-approved on this flow. Subsequent unreadable-scope consent
1499
+ // pages (post-grant confirmation, account chooser routed through
1500
+ // /consent, etc.) get the soft-advance path instead of an abort —
1501
+ // because the scope-grant decision was already made and validated.
1502
+ let consentAlreadyApproved = false;
1303
1503
  for (let i = 0; i < MAX_OAUTH_NAV; i++) {
1304
1504
  if (this.browser.oauthPageClosed()) {
1305
1505
  steps.push(`OAuth: the ${provider.label} window closed — handshake returned to the service`);
@@ -1316,10 +1516,31 @@ export class SignupAgent {
1316
1516
  continue;
1317
1517
  }
1318
1518
  const authState = provider.classifyAuthState(url, body);
1319
- steps.push(`OAuth: ${provider.label} auth state = ${authState}`);
1519
+ steps.push(`OAuth: ${provider.label} auth state = ${authState} (url=${url.slice(0, 120)})`);
1320
1520
  if (authState === "not_provider")
1321
1521
  break; // flow left the provider — back on the service
1322
1522
  if (authState === "challenge") {
1523
+ // Google's number-match challenge ("Tap N on your phone") is
1524
+ // resolvable by the user without re-running the login flow —
1525
+ // surface the number and wait for them to complete it.
1526
+ if (provider.id === "google") {
1527
+ const matchNum = extractGoogleNumberMatch(body);
1528
+ if (matchNum !== null) {
1529
+ steps.push(`Google: match the number ${matchNum} on your phone — ` +
1530
+ `open the Google app on your phone and tap ${matchNum}`);
1531
+ const cleared = await this.waitForGoogleChallenge(provider, steps);
1532
+ if (!cleared) {
1533
+ return this.oauthAbort("needs_login", `Google number-match challenge timed out after 2 minutes. ` +
1534
+ `Re-run \`${loginCmd}\`, complete the challenge in the window, then retry.`, steps);
1535
+ }
1536
+ steps.push("Google: challenge cleared — continuing OAuth");
1537
+ // Re-classify on the next iteration without burning the
1538
+ // OAuth-navigation budget (which assumes continuous
1539
+ // browser progress, not a 2-minute human pause).
1540
+ i--;
1541
+ continue;
1542
+ }
1543
+ }
1323
1544
  return this.oauthAbort("needs_login", `${provider.label} interrupted the sign-in with a security challenge ("verify it's you"). ` +
1324
1545
  `Re-run \`${loginCmd}\`, clear the challenge in the window, then retry.`, steps);
1325
1546
  }
@@ -1338,15 +1559,58 @@ export class SignupAgent {
1338
1559
  }
1339
1560
  // Genuine consent screen / account chooser — scope-gate it (T7).
1340
1561
  const scopes = extractOAuthScopes(url);
1562
+ // Always surface the parsed scopes so the user / debug logs see
1563
+ // exactly what tripped the gate (or what was allowed through).
1564
+ steps.push(`OAuth: parsed consent scopes = [${scopes === null ? "<unreadable>" : scopes.join(", ")}]`);
1341
1565
  if (scopes === null) {
1566
+ // Defense-in-depth: scrape the page DOM for known scope-grant
1567
+ // verb phrases ("See your", "Manage your contacts", "Send email
1568
+ // on your behalf", etc.). A real scope-grant consent always
1569
+ // lists each scope visually with one of these patterns. An
1570
+ // intermediate page (account chooser, post-grant confirmation,
1571
+ // safety review) does not.
1572
+ const dangerPhrases = provider.id === "google" ? scrapeGoogleScopePhrases(body) : [];
1573
+ if (dangerPhrases.length > 0) {
1574
+ return this.oauthAbort("oauth_consent_needs_review", `${provider.label} consent page (URL unparseable) lists scope-grant phrases: ` +
1575
+ `[${dangerPhrases.join(" | ")}]. Pausing for manual review.`, steps);
1576
+ }
1577
+ if (consentAlreadyApproved) {
1578
+ // We already validated and auto-approved a scope-grant
1579
+ // consent earlier in this flow. This second consent-classed
1580
+ // page has no parseable scopes AND no visible scope-grant
1581
+ // verb phrases — it's a post-grant confirmation / safety
1582
+ // review / account chooser routed through /consent. Soft
1583
+ // advance: try the approve control, and if it isn't there
1584
+ // the loop will re-classify on the next iteration.
1585
+ steps.push("OAuth: post-grant consent page (no parseable scopes, no scope phrases) — advancing");
1586
+ const advanced = await this.browser.advanceOAuthConsent(provider.id);
1587
+ if (!advanced) {
1588
+ steps.push("OAuth: no approve control on the post-grant page — waiting for natural navigation");
1589
+ }
1590
+ await this.browser.wait(3);
1591
+ continue;
1592
+ }
1342
1593
  return this.oauthAbort("oauth_consent_needs_review", `reached a ${provider.label} consent screen but could not read its requested scopes ` +
1343
1594
  `from the URL — pausing for manual review rather than approving blind.`, steps);
1344
1595
  }
1345
- if (!provider.scopesAreBasic(scopes)) {
1346
- return this.oauthAbort("oauth_consent_needs_review", `the consent screen requests scopes beyond basic identity (${scopes.join(", ")}). ` +
1347
- `Approve it manually the bot only auto-approves basic-identity scopes.`, steps);
1596
+ const extraAllowed = new Set(task.allowExtraOAuthScopes ?? []);
1597
+ const nonBasic = scopes.filter((s) => !provider.scopesAreBasic([s]));
1598
+ const unauthorized = nonBasic.filter((s) => !extraAllowed.has(s));
1599
+ if (unauthorized.length > 0) {
1600
+ // Encode requested scopes into the error so the MCP tool layer
1601
+ // can extract them and show the user what to approve.
1602
+ return this.oauthAbort("oauth_consent_needs_review", `${provider.label} consent requests non-basic scopes: [${unauthorized.join(", ")}]. ` +
1603
+ `All requested scopes: [${scopes.join(", ")}]. ` +
1604
+ `To proceed, re-run provision_any_service with allow_extra_oauth_scopes set to ` +
1605
+ `the scopes the user has explicitly approved.`, steps);
1606
+ }
1607
+ if (nonBasic.length > 0) {
1608
+ steps.push(`OAuth: user pre-approved extra scopes [${nonBasic.join(", ")}] — auto-approving`);
1609
+ }
1610
+ else {
1611
+ steps.push(`OAuth: consent scopes all basic (${scopes.join(", ")}) — auto-approving`);
1348
1612
  }
1349
- steps.push(`OAuth: consent scopes all basic (${scopes.join(", ")}) — auto-approving`);
1613
+ consentAlreadyApproved = true;
1350
1614
  const advanced = await this.browser.advanceOAuthConsent(provider.id);
1351
1615
  if (!advanced) {
1352
1616
  return this.oauthAbort("oauth_consent_needs_review", `reached a ${provider.label} consent screen but found no approve control to click — ` +
@@ -1366,6 +1630,7 @@ export class SignupAgent {
1366
1630
  service: task.service,
1367
1631
  maxRounds: task.postVerifyMaxRounds ?? 12,
1368
1632
  steps,
1633
+ ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
1369
1634
  });
1370
1635
  }
1371
1636
  if (credentials.api_key !== undefined) {
@@ -1408,6 +1673,32 @@ export class SignupAgent {
1408
1673
  ...this.resultTail(),
1409
1674
  };
1410
1675
  }
1676
+ // Poll the provider page until the challenge clears (the user
1677
+ // completed it on their phone) or 2 minutes elapse. Returns true on
1678
+ // resolution, false on timeout. The 2-minute cap is enough time to
1679
+ // unlock a phone, open the Google app, and tap a number; longer
1680
+ // would mask a stuck/abandoned flow.
1681
+ async waitForGoogleChallenge(provider, steps) {
1682
+ const deadline = Date.now() + 120_000;
1683
+ while (Date.now() < deadline) {
1684
+ await this.browser.wait(3);
1685
+ if (this.browser.oauthPageClosed())
1686
+ return true;
1687
+ const url = this.browser.currentUrl();
1688
+ let body;
1689
+ try {
1690
+ body = (await this.browser.extractText()).slice(0, 4000);
1691
+ }
1692
+ catch {
1693
+ continue;
1694
+ }
1695
+ const state = provider.classifyAuthState(url, body);
1696
+ if (state !== "challenge")
1697
+ return true;
1698
+ }
1699
+ steps.push("Google: challenge wait timed out after 2 minutes");
1700
+ return false;
1701
+ }
1411
1702
  // Backstop for the critical guarantee (D4): true when the active
1412
1703
  // provider page carries a credential-entry field — an expired/missing
1413
1704
  // session dropped the bot on a login form. A genuine consent screen
@@ -1581,6 +1872,7 @@ ${formatInventory(input.inventory)}`,
1581
1872
  oauth,
1582
1873
  inventory,
1583
1874
  ...(hint !== undefined ? { hint } : {}),
1875
+ ...(args.scopeHint !== undefined ? { scopeHint: args.scopeHint } : {}),
1584
1876
  });
1585
1877
  }
1586
1878
  catch (err) {
@@ -1642,7 +1934,7 @@ ${formatInventory(input.inventory)}`,
1642
1934
  await this.browser.type(nextStep.selector, nextStep.value);
1643
1935
  }
1644
1936
  else if (nextStep.kind === "select") {
1645
- await this.browser.selectOption(nextStep.selector);
1937
+ await this.browser.selectOption(nextStep.selector, nextStep.option_text);
1646
1938
  await this.browser.wait(1);
1647
1939
  }
1648
1940
  else if (nextStep.kind === "check") {
@@ -1748,7 +2040,7 @@ Schema:
1748
2040
  {"kind":"login","reason":"the page is a login form / we were signed out"}
1749
2041
  {"kind":"click","selector":"<a selector= copied verbatim from the inventory>","reason":"e.g. open the API keys page"}
1750
2042
  {"kind":"fill","selector":"<a selector= from the inventory>","value":"value","reason":"unusual — only for a required project-name etc."}
1751
- {"kind":"select","selector":"<a selector= from the inventory, tag=select>","reason":"pick an option for a dropdown — region, role, country"}
2043
+ {"kind":"select","selector":"<a selector= from the inventory>","option_text":"<visible label of the option to pick — optional>","reason":"pick an option for a dropdown — region, role, country, or a permission/scope on a token form"}
1752
2044
  {"kind":"check","selector":"<a selector= from the inventory, type=checkbox>","reason":"tick a terms-of-service / agreement checkbox"}
1753
2045
  {"kind":"navigate","url":"https://...","reason":"e.g. go directly to /settings/api-keys"}
1754
2046
  {"kind":"wait","seconds":N,"reason":"page is still loading"}
@@ -1775,11 +2067,16 @@ Strategy:
1775
2067
  ${loginGuidance}
1776
2068
  - If we're on a "verify your phone" / "verify email" wall, return done (we can't solve those).
1777
2069
  - If the page wants the user to create a project/key before showing it, fill the minimum and click create.
1778
- - For a required dropdown (an inventory entry with tag=select region, role, country), use {"kind":"select"} — a "click" cannot pick a <select> option, so do not click it repeatedly.
2070
+ - For ANY dropdown native (tag=select) OR a custom combobox (role=combobox / aria-haspopup=listbox, common on modern React apps like Sentry / Stripe / Vercel) use {"kind":"select"}. "click" on a combobox trigger opens it but does not pick an option; do not click it repeatedly.
2071
+ - When you need a SPECIFIC option from the dropdown — e.g. "Project: Read" on Sentry's permissions picker, or a specific region — include "option_text" with the visible label. The executor matches it case-insensitively as a substring. Omit "option_text" when any option is fine (a placeholder country picker).
1779
2072
  - A post-OAuth onboarding form (organization name, region, terms) is normal — fill/select/check its fields and click Continue to advance toward the dashboard; do not return "done" just because it is a form.
1780
2073
  - If a "Create"/"Continue" button is disabled, look for a required terms-of-service / agreement checkbox and tick it with {"kind":"check"} — use the checkbox's own inventory selector (an entry with type=checkbox), NOT the adjacent "Terms of Service" link. A "click" on a styled checkbox often fails to flip it; use "check".
1781
2074
  - Prefer the simplest credential path: a project- or organization-level API token / auth token usually needs only a name. A "personal token" with a grid of per-scope permission dropdowns is more work — choose it only if no simpler token type is offered.
1782
- - On a token-creation form whose permission/scope dropdowns default to "No Access" / "None", you MUST use a select step to set a non-default permission on at least one dropdown BEFORE clicking the create button — creating with all-default permissions does nothing. Do not click the create button repeatedly; set a permission first.
2075
+ - On a token-creation form whose permission/scope dropdowns default to "No Access" / "None", you MUST set permissions BEFORE clicking the create button.
2076
+ - **PERMISSION SCOPE — default is MAXIMUM.** ${input.scopeHint !== undefined
2077
+ ? `The user provided a scope hint: "${input.scopeHint}". Pick option_text values aligned with this on each permission dropdown.`
2078
+ : `No scope hint was provided. Default to the HIGHEST available permission level on EVERY permission dropdown (Admin > Write > Read > anything lower). Most agent use-cases need write access; a read-only token will fail downstream when the agent tries to push data. Set "Admin" if offered; "Write" otherwise. Explicitly use option_text to specify — do NOT rely on first-option behavior, which often picks Read.`}
2079
+ - On a form with MULTIPLE permission rows (Sentry: Project, Team, Member, Issue, Event, Release, Organization), set EACH ONE before clicking Create. One step per turn — return to this turn-by-turn until every row is set.
1783
2080
  - Round ${input.round + 1} of ${input.maxRounds}. Prefer "done" if you're not making progress.`;
1784
2081
  const userBlocks = [
1785
2082
  { kind: "image", media_type: "image/png", data_base64: input.state.screenshot },
@@ -1824,22 +2121,137 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
1824
2121
  },
1825
2122
  });
1826
2123
  }
1827
- async findSignupLink() {
2124
+ // Pick a signup link out of the current page's HTML. Used as the
2125
+ // fallback after a Google-search navigation.
2126
+ //
2127
+ // The naive version (regex /href="[^"]*signup[^"]*"/) failed badly
2128
+ // on Google search results: it matched URLs like
2129
+ // accounts.google.com/SignOutOptions?continue=...search?q=Sentry%20signup
2130
+ // — Google's own nav, whose ?continue= query param leaks the
2131
+ // original search query (with "signup" in it) and gets matched.
2132
+ // The bot then navigated to a Google sign-out page and gave up.
2133
+ //
2134
+ // This version:
2135
+ // - parses each href as a real URL
2136
+ // - rejects google.com / accounts.google.com / support.google.com
2137
+ // and other Google nav infra (we're ON a google search page, so
2138
+ // any google.com href is search-nav, not the service)
2139
+ // - matches against host+path only — never query params
2140
+ // - scores candidates: hosts that contain the service name win
2141
+ // over generic matches. Means "sentry.io/signup" beats
2142
+ // "github.com/sentry/sentry/blob/...signup..." (the github
2143
+ // source-code result that mentions signup in a path).
2144
+ // - returns the highest-scoring candidate, or null.
2145
+ async findSignupLink(serviceName) {
1828
2146
  const html = (await this.browser.getState()).html;
1829
- const re = /href="([^"]*(?:signup|register|sign-up|create-account|join)[^"]*)"/gi;
2147
+ const serviceSlug = serviceName?.toLowerCase().replace(/[^a-z0-9]/g, "") ?? "";
2148
+ const candidates = [];
2149
+ const hrefRe = /href="([^"]+)"/g;
1830
2150
  let m;
1831
- while ((m = re.exec(html)) !== null) {
1832
- const href = m[1];
1833
- if (href === undefined)
2151
+ while ((m = hrefRe.exec(html)) !== null) {
2152
+ const raw = m[1];
2153
+ if (raw === undefined)
2154
+ continue;
2155
+ let url;
2156
+ try {
2157
+ url = new URL(raw.startsWith("//") ? `https:${raw}` : raw);
2158
+ }
2159
+ catch {
2160
+ continue;
2161
+ }
2162
+ if (url.protocol !== "https:" && url.protocol !== "http:")
1834
2163
  continue;
1835
- if (href.includes("signin") || href.includes("login"))
2164
+ // Reject Google's own navigation infrastructure — that's what
2165
+ // tripped the naive regex on the Sentry run.
2166
+ if (/(?:^|\.)google\.com$/.test(url.hostname))
1836
2167
  continue;
1837
- if (href.startsWith("http"))
1838
- return href;
1839
- if (href.startsWith("//"))
1840
- return `https:${href}`;
2168
+ if (/(?:^|\.)googleusercontent\.com$/.test(url.hostname))
2169
+ continue;
2170
+ if (/(?:^|\.)gstatic\.com$/.test(url.hostname))
2171
+ continue;
2172
+ // Match against host+path ONLY. Query params can carry the
2173
+ // original search query text and would re-introduce the
2174
+ // junk-link bug.
2175
+ const hostPath = (url.hostname + url.pathname).toLowerCase();
2176
+ if (!/(?:^|\.|\/)(?:signup|register|sign-up|create-account|join)\b/.test(hostPath)) {
2177
+ continue;
2178
+ }
2179
+ // Negative: signin/login/logout in host+path.
2180
+ if (/(?:^|\/)(?:signin|login|logout|sign-in|log-in)\b/.test(hostPath))
2181
+ continue;
2182
+ // Score: a host containing the service slug is a strong match.
2183
+ // Without a slug to compare against, every match scores 1.
2184
+ const hostLower = url.hostname.toLowerCase();
2185
+ const score = serviceSlug.length > 0 && hostLower.includes(serviceSlug) ? 10 : 1;
2186
+ candidates.push({ url: url.toString(), score });
1841
2187
  }
1842
- return null;
2188
+ candidates.sort((a, b) => b.score - a.score);
2189
+ return candidates[0]?.url ?? null;
2190
+ }
2191
+ // Heuristic: does the currently-loaded page LOOK like a real signup
2192
+ // page? Used to decide whether the guessed canonical URL
2193
+ // (<service>.com/signup) worked or we need to fall back to a Google
2194
+ // search.
2195
+ //
2196
+ // Three signals, in order:
2197
+ // 1. URL-path shortcut: if the page's pathname matches
2198
+ // /signup|register|sign-up|create-account|join/, trust it —
2199
+ // we navigated to a signup-shaped URL and the redirect chain
2200
+ // kept us on one. Catches Sentry-style cross-TLD redirects
2201
+ // (sentry.com → sentry.io/signup) where the inventory looks
2202
+ // different from a typical signup page but the URL is correct.
2203
+ // 2. 404 guard: drop pages whose title shouts 404 / not found.
2204
+ // 3. Content check: inventory has at least one text/email input
2205
+ // OR a button whose text mentions Google/GitHub (broad on
2206
+ // purpose — a "Continue with Google" / "Login with Google" /
2207
+ // icon-only Google button all count when the bot has a
2208
+ // provider session).
2209
+ async looksLikeSignupPage() {
2210
+ const state = await this.browser.getState();
2211
+ // 1. URL-path shortcut. If we navigated to a signup-shaped path
2212
+ // and the browser kept us on one, that's a strong signal —
2213
+ // redirect chains often preserve the path across TLD changes.
2214
+ try {
2215
+ const path = new URL(state.url).pathname.toLowerCase();
2216
+ if (/(?:^|\/)(?:signup|register|sign-up|create-account|join)\b/.test(path)) {
2217
+ return true;
2218
+ }
2219
+ }
2220
+ catch {
2221
+ // Malformed state.url — skip the shortcut, fall through.
2222
+ }
2223
+ // 2. 404 guard.
2224
+ const titleLower = (state.title ?? "").toLowerCase();
2225
+ if (titleLower.includes("404") ||
2226
+ titleLower.includes("not found") ||
2227
+ titleLower.includes("page not found")) {
2228
+ return false;
2229
+ }
2230
+ // 3. Inventory check.
2231
+ let inventory;
2232
+ try {
2233
+ inventory = await this.browser.extractInteractiveElements();
2234
+ }
2235
+ catch {
2236
+ return true;
2237
+ }
2238
+ const hasInput = inventory.some((e) => e.tag === "input" &&
2239
+ (e.type === "email" || e.type === "text" || e.type === null || e.type === undefined));
2240
+ if (hasInput)
2241
+ return true;
2242
+ // Broad OAuth-button detection: any element whose visible text or
2243
+ // aria-label mentions "google" or "github" as a word. Covers
2244
+ // "Continue with Google", "Login with Google", "Use Google",
2245
+ // "Sign in with GitHub", and icon-only buttons with
2246
+ // aria-label="Google" — all common on OAuth-only signup pages.
2247
+ // False positives (e.g. a "Google Tag Manager" footer link)
2248
+ // are unlikely on a real signup view and harmless: the worst
2249
+ // case is we trust this page and the downstream planner gives
2250
+ // up cleanly later.
2251
+ return inventory.some((e) => {
2252
+ const text = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`.toLowerCase();
2253
+ return /\b(?:google|github)\b/.test(text);
2254
+ });
1843
2255
  }
1844
2256
  async extractCredentials() {
1845
2257
  // IMPORTANT: pull credentials from the *visible* page, not the raw
@@ -1847,27 +2259,132 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
1847
2259
  // Turnstile, hCaptcha) whose challenge tokens look like API keys to
1848
2260
  // a naive regex.
1849
2261
  //
1850
- // Two visible surfaces, in priority order:
1851
- // 1. Discrete credential candidates — copy-input values and each
1852
- // element's own direct text. A key is read whole here, un-glued
1853
- // from adjacent buttons; captcha tokens (hidden inputs) are
1854
- // excluded by the browser.
1855
- // 2. The whole visible body text fallback for a key shown as
1856
- // plain prose, accepting that body concatenation can glue
1857
- // neighbours (the extractApiKeyFromText guards catch the worst).
2262
+ // Three-pass extraction, in priority order:
2263
+ // 1. Visible candidates — input values + each element's direct
2264
+ // text. A key read whole, un-glued from adjacent buttons.
2265
+ // 2. F10: when pass 1 hits a TRUNCATED display (modal shows
2266
+ // "sk-or-v1-1687…" with the full secret only on the
2267
+ // clipboard via the Copy button), click the Copy button and
2268
+ // re-extract from `navigator.clipboard.readText()`. This is
2269
+ // the OpenRouter / Anthropic / OpenAI / Stripe modal
2270
+ // pattern — pass 1 would otherwise persist a truncated stub.
2271
+ // 3. F10 fallback: walk hidden inputs. Some modals stash the
2272
+ // full secret in a `display:none` <input> the masked display
2273
+ // reads from.
1858
2274
  const credentials = {};
1859
2275
  let apiKey = null;
2276
+ let truncatedHit = null;
1860
2277
  for (const candidate of await this.browser.extractCredentialCandidates()) {
1861
- apiKey = extractApiKeyFromText(candidate);
1862
- if (apiKey !== null)
1863
- break;
2278
+ const hit = extractApiKeyFromText(candidate);
2279
+ if (hit === null)
2280
+ continue;
2281
+ if (isTruncatedCapture(candidate, hit)) {
2282
+ // Remember the truncated value but keep scanning — a later
2283
+ // candidate may produce a full one (e.g. a hidden input on
2284
+ // the same page).
2285
+ truncatedHit = truncatedHit ?? hit;
2286
+ continue;
2287
+ }
2288
+ apiKey = hit;
2289
+ break;
1864
2290
  }
1865
2291
  if (apiKey === null) {
1866
- apiKey = extractApiKeyFromText(await this.browser.extractText());
2292
+ const bodyText = await this.browser.extractText();
2293
+ const hit = extractApiKeyFromText(bodyText);
2294
+ if (hit !== null) {
2295
+ if (isTruncatedCapture(bodyText, hit)) {
2296
+ truncatedHit = truncatedHit ?? hit;
2297
+ }
2298
+ else {
2299
+ apiKey = hit;
2300
+ }
2301
+ }
2302
+ }
2303
+ // Pass 2 — Copy-button + clipboard recovery.
2304
+ if (apiKey === null && truncatedHit !== null) {
2305
+ apiKey = await this.tryCopyButtonExtraction();
2306
+ }
2307
+ // Pass 3 — hidden-input scan. Cheap to always try as a last
2308
+ // resort, whether or not we saw a truncated hit; a service that
2309
+ // stashes the key in a hidden input may not display it at all.
2310
+ if (apiKey === null) {
2311
+ try {
2312
+ for (const value of await this.browser.extractAllInputValues()) {
2313
+ const hit = extractApiKeyFromText(value);
2314
+ if (hit !== null && !isTruncatedCapture(value, hit)) {
2315
+ apiKey = hit;
2316
+ break;
2317
+ }
2318
+ }
2319
+ }
2320
+ catch {
2321
+ // Hidden-input scan failures are non-fatal; we just stay
2322
+ // with whatever we had (or null).
2323
+ }
2324
+ }
2325
+ // Last resort: if every path returned a truncated value, persist
2326
+ // it with a `_truncated` suffix so the host agent can surface the
2327
+ // partial result to the user (better than reporting "no key
2328
+ // found" when the bot demonstrably reached the modal).
2329
+ if (apiKey === null && truncatedHit !== null) {
2330
+ credentials.api_key_truncated = truncatedHit;
2331
+ return credentials;
1867
2332
  }
1868
2333
  if (apiKey !== null)
1869
2334
  credentials.api_key = apiKey;
1870
2335
  return credentials;
1871
2336
  }
2337
+ // F10: click the page's Copy button (whose label typically reads
2338
+ // "Copy", "Copy key", "Copy secret") and extract the secret from
2339
+ // `navigator.clipboard.readText()`. Returns null on any failure —
2340
+ // the caller has its own fallback paths.
2341
+ async tryCopyButtonExtraction() {
2342
+ let copyBtnSelector = null;
2343
+ try {
2344
+ const inventory = await this.browser.extractInteractiveElements();
2345
+ const copyBtn = inventory.find((e) => {
2346
+ const text = `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`.trim();
2347
+ // "Copy" alone, "Copy key", "Copy API key", "Copy secret",
2348
+ // "Copy token". Anchored so a "Don't copy this" tooltip
2349
+ // doesn't match. Case-insensitive.
2350
+ return /^\s*copy(?:\b|\s|$)|copy\s+(?:api\s*key|secret|token|key)\b/i.test(text);
2351
+ });
2352
+ if (copyBtn === undefined)
2353
+ return null;
2354
+ copyBtnSelector = copyBtn.selector;
2355
+ }
2356
+ catch {
2357
+ return null;
2358
+ }
2359
+ try {
2360
+ await this.browser.click(copyBtnSelector);
2361
+ // Brief wait — the Copy button's onclick is usually a sync
2362
+ // navigator.clipboard.writeText, but some modals run an async
2363
+ // serialize step (e.g. format-the-key into "Bearer <key>"
2364
+ // first). 1s covers both with no real cost.
2365
+ await this.browser.wait(1);
2366
+ }
2367
+ catch {
2368
+ return null;
2369
+ }
2370
+ let clipboardText;
2371
+ try {
2372
+ clipboardText = await this.browser.readClipboard();
2373
+ }
2374
+ catch {
2375
+ return null;
2376
+ }
2377
+ if (clipboardText.trim().length === 0)
2378
+ return null;
2379
+ const fromClipboard = extractApiKeyFromText(clipboardText);
2380
+ if (fromClipboard === null)
2381
+ return null;
2382
+ // Sanity: don't accept a clipboard hit that is ITSELF truncated
2383
+ // (some Copy buttons copy the masked display rather than the
2384
+ // real value — defensive against that surprising case).
2385
+ if (isTruncatedCapture(clipboardText, fromClipboard))
2386
+ return null;
2387
+ return fromClipboard;
2388
+ }
1872
2389
  }
1873
2390
  //# sourceMappingURL=agent.js.map