@trusty-squire/mcp 0.8.2-rc.8 → 0.8.2

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.
package/dist/bot/agent.js CHANGED
@@ -145,6 +145,136 @@ export class LLMCallBudgetExceeded extends Error {
145
145
  this.name = "LLMCallBudgetExceeded";
146
146
  }
147
147
  }
148
+ // 0.8.2-rc.10 — common dashboard paths that vendors host their
149
+ // per-account API key UI at. Ordered most-specific first so a
150
+ // fallback navigate doesn't land short of the actual page. Returned
151
+ // as an array of path-strings; the caller composes them onto the
152
+ // origin of the currently-stuck URL and skips any already tried.
153
+ //
154
+ // Patterns harvested from Anthropic (settings/keys), Sentry
155
+ // (settings/account/api/auth-tokens), Neon (settings#api-keys),
156
+ // Render (account/api-keys), Postmark (account/api_tokens),
157
+ // OpenRouter (keys), and a long tail of vendors converging on the
158
+ // same conventions.
159
+ const STUCK_LOOP_FALLBACK_PATHS = [
160
+ "/settings/keys",
161
+ "/settings/api-keys",
162
+ "/settings/api_keys",
163
+ "/settings/tokens",
164
+ "/settings/api-tokens",
165
+ "/settings/account/api/auth-tokens/",
166
+ "/account/api-keys",
167
+ "/account/api_tokens",
168
+ "/account/keys",
169
+ "/account/tokens",
170
+ "/api-keys",
171
+ "/api_keys",
172
+ "/keys",
173
+ "/tokens",
174
+ "/auth-tokens",
175
+ "/dashboard/api-keys",
176
+ "/dashboard/keys",
177
+ ];
178
+ // 0.8.2-rc.10 — heuristic for "this account already exists on the
179
+ // service and its API keys are masked, with no path to reveal them."
180
+ // The test identity (methoxine@gmail.com) accumulates state across
181
+ // batches; subsequent runs land on a dashboard whose API-keys page
182
+ // shows only the NAMES of existing keys (the values were revealed
183
+ // once at create-time and aren't recoverable). Without this
184
+ // classifier those runs fall through to a generic
185
+ // oauth_onboarding_failed and the harvester treats them like a
186
+ // repairable bug.
187
+ //
188
+ // Conservative rules: must be on a URL that names an API-key page
189
+ // (keys / api-keys / api-tokens / auth-tokens / api_keys), AND the
190
+ // page text shows BOTH a masking glyph pattern (•••, ***, ─•) AND
191
+ // an existing-key word, OR the planner's last reason explicitly
192
+ // describes the same shape.
193
+ const EXISTING_KEY_URL_HINT = /(?:api[-_/]keys?|api[-_/]tokens?|auth[-_/]tokens?|personal[-_/]access[-_/]tokens?|\/keys(?:\b|\/|$)|\/tokens(?:\b|\/|$)|\/settings\/keys\b|\/settings\/tokens\b|#api[-_/]keys\b|#api[-_/]tokens\b)/i;
194
+ const MASKED_KEY_GLYPHS = /(?:•{3,}|\*{3,}|─•|·{3,}|•{3,}|x{6,}|[A-Za-z0-9]{2,4}[•*]{5,})/;
195
+ // 0.8.2-rc.12 — widened to catch Neon's existing-key list shape
196
+ // (the per-row layout has a "Key name" header + "Created <date>" +
197
+ // "Last used <date|never>" — no glyph, no "existing" word, just the
198
+ // columns of an API-key listing table). The conservative AND with
199
+ // EXISTING_KEY_URL_HINT keeps this from misfiring on marketing copy
200
+ // elsewhere on a non-keys URL.
201
+ const EXISTING_KEY_WORDS = /\b(?:existing\s+(?:api\s+)?(?:key|token)|previously\s+created|created\s+by\b|api\s+keys?\s*\(\d+\)|tokens?\s*\(\d+\)|reveal|copy\s+key|key\s+name\b|last\s+used\b|created(?:\s+\w+){0,3}\s+(?:\d{1,2},?\s+)?\d{4}\b)/i;
202
+ const NO_CREATE_AFFORDANCE_HINT = /\b(?:cannot\s+(?:reveal|extract|read)|values?\s+(?:is\s+)?masked|only\s+shown\s+once|cannot\s+(?:see|view|copy)\s+(?:the\s+)?(?:key|secret|value)|key\s+(?:value|secret)\s+(?:is\s+)?(?:not\s+)?(?:available|recoverable|extractable|shown))\b/i;
203
+ export function detectExistingAccountNoExtract(input) {
204
+ if (!EXISTING_KEY_URL_HINT.test(input.url))
205
+ return false;
206
+ // Planner reason naming the no-reveal shape is the strongest single
207
+ // signal — the planner has SEEN the page and is describing it.
208
+ if (NO_CREATE_AFFORDANCE_HINT.test(input.lastPlannerReason)) {
209
+ return true;
210
+ }
211
+ // 0.8.2-rc.12 — three independent positive paths, ANY of which is
212
+ // enough since we already gated on the URL matching an API-keys
213
+ // page (which alone weeds out the marketing-tile false-positives
214
+ // the conservative pre-rc.12 path was protecting against):
215
+ // 1. Mask glyphs in the page (•••, asterisks, ··· — the literal
216
+ // "value is hidden" decoration most vendors use).
217
+ // 2. Two or more existing-key word patterns matched (a key
218
+ // LISTING shape: "Key name" + "Last used" + "Created <date>"
219
+ // is unmistakable when found on a /keys-style URL).
220
+ // 3. Mask glyph PLUS any existing-key word (the original
221
+ // detector — keeps the conservative behavior for vendors
222
+ // whose listing UI uses different column labels).
223
+ const hasMaskGlyph = MASKED_KEY_GLYPHS.test(input.pageText);
224
+ // Tally up to 5 distinct existing-key signals; 2+ is enough.
225
+ const existingKeyMatches = [];
226
+ const allWords = input.pageText.match(new RegExp(EXISTING_KEY_WORDS, "gi"));
227
+ if (allWords !== null) {
228
+ const distinct = new Set();
229
+ for (const m of allWords) {
230
+ distinct.add(m.toLowerCase().replace(/\s+/g, " "));
231
+ if (distinct.size >= 5)
232
+ break;
233
+ }
234
+ existingKeyMatches.push(...distinct);
235
+ }
236
+ if (hasMaskGlyph && existingKeyMatches.length >= 1)
237
+ return true;
238
+ if (existingKeyMatches.length >= 2)
239
+ return true;
240
+ if (hasMaskGlyph && /\bAPI\s+keys?\b/i.test(input.pageText))
241
+ return true;
242
+ return false;
243
+ }
244
+ // Pick the next fallback URL to try from STUCK_LOOP_FALLBACK_PATHS
245
+ // keyed against the origin of the currently-stuck URL. Returns null
246
+ // when every path has already been attempted. Exported for unit tests.
247
+ export function pickStuckLoopFallbackUrl(currentUrl, alreadyTried) {
248
+ let origin;
249
+ try {
250
+ origin = new URL(currentUrl).origin;
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ // Skip a candidate when the current URL's path ALREADY matches it
256
+ // (case-insensitive, trailing-slash tolerant). The planner is stuck
257
+ // ON the page the candidate points to — navigating to the same URL
258
+ // again won't break the cycle, only a different path will.
259
+ const currentPath = (() => {
260
+ try {
261
+ return new URL(currentUrl).pathname.replace(/\/+$/, "").toLowerCase();
262
+ }
263
+ catch {
264
+ return "";
265
+ }
266
+ })();
267
+ for (const path of STUCK_LOOP_FALLBACK_PATHS) {
268
+ const candidate = `${origin}${path}`;
269
+ if (alreadyTried.has(candidate))
270
+ continue;
271
+ const candidatePath = path.replace(/\/+$/, "").toLowerCase();
272
+ if (candidatePath === currentPath)
273
+ continue;
274
+ return candidate;
275
+ }
276
+ return null;
277
+ }
148
278
  // Best-effort canonical signup URL for a service when the caller
149
279
  // didn't pass one. Most dev-SaaS targets (Resend, Postmark, Mailgun,
150
280
  // MailerSend, IPInfo, Stripe, PostHog) live at <name>.com/signup —
@@ -1461,6 +1591,43 @@ export function extractAllLabeledTokensFromReason(reason, pageText) {
1461
1591
  function escapeRegex(s) {
1462
1592
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1463
1593
  }
1594
+ // Keys that the postVerifyLoop's accumulator stores for housekeeping —
1595
+ // they're NOT extracted credentials and must NOT count as "we found
1596
+ // something" when deciding whether an extract round succeeded.
1597
+ const NON_CREDENTIAL_KEYS = new Set([
1598
+ "api_key_truncated", // truncated stub from extractCredentials Pass 1
1599
+ "password", // signup form metadata (email-verification path)
1600
+ "email", // signup form metadata
1601
+ ]);
1602
+ // True iff the credentials Record holds at least one extracted value
1603
+ // (api_key, username, or any labeled multi-cred field). Excludes
1604
+ // metadata + truncated stubs. Used to decide "this extract round
1605
+ // produced something — continue the loop / capture a synthetic extract
1606
+ // round" vs "every tier missed — try the planner-quoted fallback".
1607
+ export function hasAnyExtractedCredential(creds) {
1608
+ for (const key of Object.keys(creds)) {
1609
+ if (NON_CREDENTIAL_KEYS.has(key))
1610
+ continue;
1611
+ return true;
1612
+ }
1613
+ return false;
1614
+ }
1615
+ // True iff the credentials Record contains a multi-credential bundle
1616
+ // — anything beyond the legacy single api_key/username pair. Used by
1617
+ // the post-verify loop's early-exit so a partial multi-cred capture
1618
+ // doesn't return prematurely (Cloudinary's api_key surfaces 4-5
1619
+ // rounds before api_secret; the legacy exit fired the moment api_key
1620
+ // was set, losing cloud_name + api_secret).
1621
+ export function isMultiCredBundle(creds) {
1622
+ for (const key of Object.keys(creds)) {
1623
+ if (NON_CREDENTIAL_KEYS.has(key))
1624
+ continue;
1625
+ if (key === "api_key" || key === "username")
1626
+ continue;
1627
+ return true;
1628
+ }
1629
+ return false;
1630
+ }
1464
1631
  export function extractApiKeyFromText(text) {
1465
1632
  const prefixed = [
1466
1633
  /\bre_[a-zA-Z0-9_]{20,}\b/, // Resend (key body contains underscores)
@@ -2766,6 +2933,34 @@ export class SignupAgent {
2766
2933
  ...this.resultTail(),
2767
2934
  };
2768
2935
  }
2936
+ // 0.8.2-rc.10 — same sentinel-pattern routing the runOAuthFlow
2937
+ // path uses. The post-verify loop sets lastPostVerifyDoneReason
2938
+ // with [stuck_loop] or [existing_account_no_extract] markers
2939
+ // when it bails on a planner-loop or pre-existing-key state;
2940
+ // surface those distinctly rather than as the generic
2941
+ // no_credentials_after_already_signed_in.
2942
+ if (this.lastPostVerifyDoneReason !== null &&
2943
+ this.lastPostVerifyDoneReason.startsWith("[stuck_loop]")) {
2944
+ return {
2945
+ success: false,
2946
+ error: `planner_stuck: ${task.service}'s dashboard re-picked the same step repeatedly ` +
2947
+ `with no inventory change and the bot's hardcoded API-key URL fallbacks did not ` +
2948
+ `advance the page — finish the signup manually.`,
2949
+ steps,
2950
+ ...this.resultTail(),
2951
+ };
2952
+ }
2953
+ if (this.lastPostVerifyDoneReason !== null &&
2954
+ this.lastPostVerifyDoneReason.startsWith("[existing_account_no_extract]")) {
2955
+ return {
2956
+ success: false,
2957
+ error: `existing_account_no_extract: ${task.service}'s dashboard shows pre-existing API ` +
2958
+ `keys for this identity but the values are masked and unrecoverable — wipe the ` +
2959
+ `test identity's account on ${task.service} or sign in manually and reveal the key.`,
2960
+ steps,
2961
+ ...this.resultTail(),
2962
+ };
2963
+ }
2769
2964
  return {
2770
2965
  success: false,
2771
2966
  error: "no_credentials_after_already_signed_in: bot detected an authenticated dashboard " +
@@ -3359,14 +3554,20 @@ export class SignupAgent {
3359
3554
  }
3360
3555
  }
3361
3556
  let credentials = await this.extractCredentials();
3362
- if (credentials.api_key === undefined) {
3363
- credentials = await this.postVerifyLoop({
3364
- service: task.service,
3365
- maxRounds: task.postVerifyMaxRounds ?? 12,
3366
- steps,
3367
- ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
3368
- });
3369
- }
3557
+ // 0.8.2-rc.15 always enter postVerifyLoop. The legacy short-
3558
+ // circuit ("only call postVerifyLoop if api_key wasn't already
3559
+ // visible") returned early on multi-cred services that happen to
3560
+ // land with api_key plain-visible — cloud_name + api_secret on
3561
+ // Cloudinary, application_id + admin_api_key on Algolia — and the
3562
+ // siblings were never extracted. postVerifyLoop's top-of-iter
3563
+ // early-exit is itself multi-cred-aware (rc.13), so when there's
3564
+ // nothing more to do, it returns on the first iteration.
3565
+ credentials = await this.postVerifyLoop({
3566
+ service: task.service,
3567
+ maxRounds: task.postVerifyMaxRounds ?? 12,
3568
+ steps,
3569
+ ...(task.scopeHint !== undefined ? { scopeHint: task.scopeHint } : {}),
3570
+ });
3370
3571
  if (credentials.api_key !== undefined) {
3371
3572
  return {
3372
3573
  success: true,
@@ -3412,6 +3613,41 @@ export class SignupAgent {
3412
3613
  ...this.resultTail(),
3413
3614
  };
3414
3615
  }
3616
+ // 0.8.2-rc.10 — planner stuck-loop, fallback URLs exhausted. The
3617
+ // postVerifyLoop marks this with the [stuck_loop] sentinel so the
3618
+ // operator sees a distinct status (it's not an "OAuth onboarding"
3619
+ // failure — OAuth succeeded; the planner got stuck on the
3620
+ // post-OAuth navigation).
3621
+ if (this.lastPostVerifyDoneReason !== null &&
3622
+ this.lastPostVerifyDoneReason.startsWith("[stuck_loop]")) {
3623
+ return {
3624
+ success: false,
3625
+ error: `planner_stuck: ${task.service}'s post-OAuth dashboard re-picked the same step ` +
3626
+ `repeatedly with no inventory change and the bot's hardcoded API-key URL fallbacks ` +
3627
+ `did not advance the page — finish the signup manually.`,
3628
+ steps,
3629
+ ...this.resultTail(),
3630
+ };
3631
+ }
3632
+ // 0.8.2-rc.10 — existing-account state with no extractable
3633
+ // credential. The postVerifyLoop's existing-key detector
3634
+ // (detectExistingAccountNoExtract) classifies a run that lands on
3635
+ // an authenticated dashboard whose API-keys page surfaces only
3636
+ // masked existing keys + no path to a fresh value. Surfacing this
3637
+ // distinctly so the harvester can flag it (e.g. periodically wipe
3638
+ // the chrome profile for the test identity) rather than treat it
3639
+ // as a real bot failure.
3640
+ if (this.lastPostVerifyDoneReason !== null &&
3641
+ this.lastPostVerifyDoneReason.startsWith("[existing_account_no_extract]")) {
3642
+ return {
3643
+ success: false,
3644
+ error: `existing_account_no_extract: ${task.service}'s dashboard shows pre-existing API ` +
3645
+ `keys for this identity but the values are masked and unrecoverable — wipe the ` +
3646
+ `test identity's account on ${task.service} or sign in manually and reveal the key.`,
3647
+ steps,
3648
+ ...this.resultTail(),
3649
+ };
3650
+ }
3415
3651
  return {
3416
3652
  success: false,
3417
3653
  error: `oauth_onboarding_failed: signed in to ${task.service} via ${provider.label} but ` +
@@ -3690,6 +3926,26 @@ ${formatInventory(input.inventory)}`,
3690
3926
  }
3691
3927
  async postVerifyLoop(args) {
3692
3928
  let credentials = await this.extractCredentials();
3929
+ // 0.8.2-rc.15 — also seed DOM-proximity at loop entry. If the
3930
+ // bot lands directly on the api-keys page (Cloudinary navigates
3931
+ // through onboarding to the dashboard automatically, sometimes
3932
+ // landing on /settings/api-keys), labeled siblings are visible
3933
+ // immediately and the loop's top-of-iter check (which respects
3934
+ // isMultiCredBundle) can hold the loop open for the planner to
3935
+ // emit an explicit extract. Without this seed, only api_key
3936
+ // would be set on entry and isMultiCredBundle would return
3937
+ // false → loop exits with a partial bundle.
3938
+ try {
3939
+ const labeledSeed = await this.extractFromDomProximity();
3940
+ for (const [k, v] of Object.entries(labeledSeed)) {
3941
+ if (credentials[k] === undefined)
3942
+ credentials[k] = v;
3943
+ }
3944
+ }
3945
+ catch {
3946
+ // Non-fatal — the planner's explicit extract round will run
3947
+ // DOM-proximity again, this is just an opportunistic seed.
3948
+ }
3693
3949
  let loginAttempts = 0;
3694
3950
  let planFailures = 0;
3695
3951
  // 0.8.2-rc.6 — separate counter for upstream-blip retries. Doesn't
@@ -3760,6 +4016,26 @@ ${formatInventory(input.inventory)}`,
3760
4016
  // navigate produced no progress. Inject a hint forcing a CLICK
3761
4017
  // on something visible in the current inventory.
3762
4018
  let prevNavigateFromUrl = null;
4019
+ // 0.8.2-rc.10 — escalation for the stuck-loop detector.
4020
+ //
4021
+ // The existing detector injects a re-plan hint when the planner
4022
+ // returns the same kind+selector twice with no inventory change,
4023
+ // but the planner often ignores the "pick a different KIND" hint
4024
+ // and just picks a slightly different SELECTOR for another click.
4025
+ // Anthropic's batch failure (rc.8) showed 6 wasted rounds of this
4026
+ // before a navigate finally broke the cycle: clicking the sidebar
4027
+ // "API Keys" link on a dashboard that wasn't routing to it.
4028
+ //
4029
+ // Escalation strategy: after N stuck-fires within the SAME URL,
4030
+ // try a hard navigate to a guessed API-keys URL (one per origin).
4031
+ // If the URL has already advanced past the stuck zone, reset the
4032
+ // counter. After every fallback URL is exhausted AND we're still
4033
+ // stuck, mark the run [stuck_loop] so the caller surfaces the
4034
+ // dedicated error code instead of the generic
4035
+ // oauth_onboarding_failed.
4036
+ let stuckFiresAtUrl = 0;
4037
+ let lastStuckFireUrl = null;
4038
+ const triedFallbackUrls = new Set();
3763
4039
  // 0.8.1 — capture chain index is independent of the planner loop
3764
4040
  // round. The loop has two early-`continue` paths (page mid-navigation
3765
4041
  // throw, planner-rejection re-plan) that increment `round` WITHOUT
@@ -3771,11 +4047,60 @@ ${formatInventory(input.inventory)}`,
3771
4047
  // contiguous 0..N-1 chain regardless of how many planner re-plans
3772
4048
  // happen mid-run.
3773
4049
  let capturedRound = 0;
4050
+ // 0.8.2-rc.12 — multi-cred-aware loop exit. Track the number of
4051
+ // distinct credential keys we've accumulated; if we're in a
4052
+ // multi-cred bundle (cloud_name, api_secret, application_id, …)
4053
+ // keep planning past the first api_key surfacing so siblings can
4054
+ // accumulate. Bounded by `roundsSinceLastNewCredential` so a
4055
+ // page that never produces a sibling doesn't loop forever.
4056
+ let lastCredentialKeyCount = Object.keys(credentials).filter((k) => !NON_CREDENTIAL_KEYS.has(k)).length;
4057
+ let roundsSinceLastNewCredential = 0;
4058
+ const MAX_ROUNDS_AWAITING_MORE_CREDENTIALS = 3;
4059
+ // 0.8.2-rc.16 — when the loop's pre-entry seed already had a
4060
+ // credential (Cloudinary's billing/plans page exposes the api_key
4061
+ // via a hidden field that extractCredentials catches), we cannot
4062
+ // trust that result as authoritative for multi-cred: the bot
4063
+ // hasn't navigated to a labeled api-keys page yet, so cloud_name
4064
+ // + api_secret are not yet visible to extractFromDomProximity.
4065
+ // Hold the loop open until the planner has issued at least one
4066
+ // explicit extract step — only then has the bot affirmatively
4067
+ // surveyed the labeled credentials surface.
4068
+ const seedHadCredential = credentials.api_key !== undefined || credentials.username !== undefined;
4069
+ let plannerExtractEmitted = false;
3774
4070
  for (let round = 0; round < args.maxRounds; round++) {
3775
- if (credentials.api_key !== undefined || credentials.username !== undefined) {
4071
+ const currentCredentialKeyCount = Object.keys(credentials).filter((k) => !NON_CREDENTIAL_KEYS.has(k)).length;
4072
+ if (currentCredentialKeyCount > lastCredentialKeyCount) {
4073
+ roundsSinceLastNewCredential = 0;
4074
+ lastCredentialKeyCount = currentCredentialKeyCount;
4075
+ }
4076
+ else if (lastCredentialKeyCount > 0) {
4077
+ roundsSinceLastNewCredential += 1;
4078
+ }
4079
+ // Multi-cred services hold the loop open until either the
4080
+ // planner returns `done`, the budget expires, or we've made
4081
+ // no credential progress for MAX_ROUNDS_AWAITING_MORE_CREDENTIALS
4082
+ // consecutive rounds. Single-cred services keep the legacy
4083
+ // behavior of returning the moment api_key surfaces — EXCEPT
4084
+ // when the api_key came from the pre-loop seed and the
4085
+ // planner hasn't yet emitted an explicit extract step. In
4086
+ // that case we let the planner run until extract fires.
4087
+ const inMultiCredMode = isMultiCredBundle(credentials);
4088
+ const haveOnlySeedCredentials = seedHadCredential && !plannerExtractEmitted;
4089
+ if (!inMultiCredMode &&
4090
+ (credentials.api_key !== undefined || credentials.username !== undefined) &&
4091
+ !haveOnlySeedCredentials) {
3776
4092
  args.steps.push(`Post-verify: credentials found on round ${round}.`);
3777
4093
  return credentials;
3778
4094
  }
4095
+ if (inMultiCredMode &&
4096
+ roundsSinceLastNewCredential >= MAX_ROUNDS_AWAITING_MORE_CREDENTIALS &&
4097
+ (credentials.api_key !== undefined || credentials.username !== undefined)) {
4098
+ const summary = Object.keys(credentials)
4099
+ .filter((k) => !NON_CREDENTIAL_KEYS.has(k))
4100
+ .join(", ");
4101
+ args.steps.push(`Post-verify: multi-cred bundle stable for ${roundsSinceLastNewCredential} rounds — returning what we have (${summary}).`);
4102
+ return credentials;
4103
+ }
3779
4104
  // Settle the page first — the previous round's click may have
3780
4105
  // triggered a navigation, and reading a page mid-navigation
3781
4106
  // throws "execution context destroyed". waitForFormReady is
@@ -4068,6 +4393,94 @@ ${formatInventory(input.inventory)}`,
4068
4393
  const uncheckedBoxHint = uncheckedBoxes.length > 0
4069
4394
  ? `\n\nVisible checkboxes you haven't ticked yet (often a TOS / agreement gate):\n${uncheckedBoxes.join("\n")}\n\nIssue {"kind":"check"} on any that look like agreements / required confirmations.`
4070
4395
  : "";
4396
+ // 0.8.2-rc.10 — escalation. Track stuck-fires per URL so we
4397
+ // can switch tactics once the gentle re-plan hint has clearly
4398
+ // failed (the planner refuses to break the cycle on its own,
4399
+ // see the Anthropic six-round pattern in rc.8).
4400
+ if (lastStuckFireUrl === state.url) {
4401
+ stuckFiresAtUrl += 1;
4402
+ }
4403
+ else {
4404
+ stuckFiresAtUrl = 1;
4405
+ lastStuckFireUrl = state.url;
4406
+ }
4407
+ // After two stuck fires at the same URL, escalate to a
4408
+ // hardcoded /settings/keys-style navigation. Vendors almost
4409
+ // always have ONE canonical path; the dashboard often gates
4410
+ // it behind a sidebar link the planner can't reliably resolve
4411
+ // (Anthropic, Neon, Sentry, Mistral, …). The fallback list is
4412
+ // ordered most-specific first so a service whose dashboard
4413
+ // root happens to share /settings with the API-keys page
4414
+ // doesn't land short of the actual page.
4415
+ if (stuckFiresAtUrl >= 2) {
4416
+ // 0.8.2-rc.12 — when the bot is ALREADY on a URL that names
4417
+ // an API-keys page (path contains /keys, /tokens, /api-keys,
4418
+ // etc.) AND the page text shows masked-credential markers,
4419
+ // the dashboard is genuinely showing a pre-existing key
4420
+ // we can't unmask (Neon's `ts-7229` is the canonical case —
4421
+ // the value was revealed once at create-time and is gone).
4422
+ // Skip the fallback-URL navigate entirely (it would land
4423
+ // on a 404 for vendors whose api-keys page lives at an
4424
+ // org-scoped URL like `/app/<org>/settings#api-keys`) and
4425
+ // classify as existing_account_no_extract directly.
4426
+ try {
4427
+ const stuckPageText = await this.browser
4428
+ .extractText()
4429
+ .catch(() => "");
4430
+ if (detectExistingAccountNoExtract({
4431
+ url: state.url,
4432
+ pageText: stuckPageText,
4433
+ lastPlannerReason: nextStep.reason,
4434
+ })) {
4435
+ this.lastPostVerifyDoneReason =
4436
+ `[existing_account_no_extract] stuck-loop at ${state.url} on an existing API-keys page with masked credentials; ` +
4437
+ `latest planner reason: ${nextStep.reason}`;
4438
+ args.steps.push(`Post-verify: stuck-loop on an existing-keys page — classified as existing_account_no_extract, breaking out.`);
4439
+ break;
4440
+ }
4441
+ }
4442
+ catch {
4443
+ // best-effort — fall through to the regular fallback path
4444
+ // if the page-text read failed.
4445
+ }
4446
+ const fallback = pickStuckLoopFallbackUrl(state.url, triedFallbackUrls);
4447
+ if (fallback !== null) {
4448
+ triedFallbackUrls.add(fallback);
4449
+ args.steps.push(`Post-verify: stuck-loop detected ${stuckFiresAtUrl}x at ${state.url} — escalating to a hardcoded API-key URL: ${fallback}`);
4450
+ try {
4451
+ await this.browser.goto(fallback);
4452
+ await this.browser.waitForInteractiveDom(5, 15_000);
4453
+ }
4454
+ catch (err) {
4455
+ args.steps.push(`Post-verify: stuck-loop fallback navigate failed (${err instanceof Error ? err.message : String(err)}) — continuing.`);
4456
+ }
4457
+ // Reset signature tracking so the next round starts clean
4458
+ // against the new URL's inventory. Don't reset
4459
+ // stuckFiresAtUrl here — it's keyed by URL and the URL
4460
+ // about to be observed will be different, which naturally
4461
+ // resets it on the next loop entry.
4462
+ prevSignature = null;
4463
+ prevInventorySize = -1;
4464
+ hint = undefined;
4465
+ // Don't bump capturedRound — captureOnboardingRound above
4466
+ // already wrote a capture for this round (the stuck-loop
4467
+ // detector runs AFTER the capture, so the planner's
4468
+ // observed step IS on disk). Bumping again here would
4469
+ // leave a phantom gap in the chain that verifyCaptureChain
4470
+ // rejects as missing_round.
4471
+ continue;
4472
+ }
4473
+ // Every plausible fallback URL has been tried and we're
4474
+ // still stuck. Mark with the [stuck_loop] sentinel so the
4475
+ // caller surfaces planner_stuck instead of the generic
4476
+ // oauth_onboarding_failed, then break out of the loop.
4477
+ this.lastPostVerifyDoneReason =
4478
+ `[stuck_loop] planner re-picked the same ${nextStep.kind} step ${stuckFiresAtUrl} times at ${state.url} with no inventory change; ` +
4479
+ `hardcoded API-key URL fallbacks exhausted (tried: ${[...triedFallbackUrls].join(", ") || "none"}). ` +
4480
+ `Latest planner reason: ${nextStep.reason}`;
4481
+ args.steps.push(`Post-verify: stuck-loop unresolvable — breaking out with planner_stuck.`);
4482
+ break;
4483
+ }
4071
4484
  args.steps.push(sameSelector
4072
4485
  ? `Post-verify: no-progress detected — same ${nextStep.kind} on same selector, inventory unchanged. Re-planning instead of re-running.`
4073
4486
  : `Post-verify: no-progress detected — successive click steps with no inventory change. Forcing a non-click action.`);
@@ -4137,126 +4550,128 @@ ${formatInventory(input.inventory)}`,
4137
4550
  hint = undefined;
4138
4551
  try {
4139
4552
  if (nextStep.kind === "extract") {
4140
- credentials = await this.extractCredentials();
4141
- if (credentials.api_key === undefined) {
4142
- // rc.28 planner-quoted-token fallback. The regex
4143
- // library missed (IPInfo's 14-char hex; some other
4144
- // shape) but the planner's reason often literally
4145
- // quotes the value. Accept it IF it's also present
4146
- // verbatim in the visible page text — that's the
4147
- // anti-hallucination guardrail.
4148
- // rc.38 — verify the planner-quoted value against both
4149
- // visible text AND every input's `value` attribute. The
4150
- // rc.37 Upstash retest showed the bot quoting a bare UUID
4151
- // it observed in a create-key modal whose UUID lived in
4152
- // an <input readonly value="…"> textContent doesn't
4153
- // include input values, so the verbatim-in-page check
4154
- // rejected a real credential. Concatenating input values
4155
- // closes the gap without weakening the anti-hallucination
4156
- // guarantee (the candidate still has to appear SOMEWHERE
4157
- // verifiable on the page).
4158
- const [pageText, inputValues] = await Promise.all([
4159
- this.browser.extractText().catch(() => ""),
4160
- this.browser.extractAllInputValues().catch(() => []),
4161
- ]);
4162
- const verifySource = pageText + "\n" + inputValues.join("\n");
4163
- // Phase E multi-cred-aware extraction. Try the labeled
4164
- // multi-credential parser FIRST. If the planner labeled
4165
- // 2+ distinct credentials in its reason, fold them all
4166
- // into the credentials Record. If the parser found at
4167
- // least one new value (cloud_name, api_secret, etc.
4168
- // anything beyond the single api_key the legacy path
4169
- // captures), prefer this. Falls through to the single-
4170
- // value extractQuotedTokenFromReason when no labeled
4171
- // tokens parsed (single-cred services, ad-hoc planner
4172
- // prose without explicit labels).
4173
- const labeled = extractAllLabeledTokensFromReason(nextStep.reason, verifySource);
4174
- const labeledKeys = Object.keys(labeled);
4175
- if (labeledKeys.length >= 2 || (labeledKeys.length === 1 && labeled["api_key"] === undefined)) {
4176
- credentials = { ...credentials, ...labeled };
4177
- const summary = labeledKeys
4178
- .map((k) => `${k}=${labeled[k].slice(0, 4)}…${labeled[k].slice(-4)}`)
4179
- .join(", ");
4180
- args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted ${labeledKeys.length} labeled credential(s) ` +
4181
- `via Phase E parser (${summary})`);
4182
- // When the planner's reason explicitly flags a masked
4183
- // credential ("api_secret is masked", "hidden behind
4184
- // asterisks", "click Reveal to show"), Phase E only
4185
- // captured the visible values — try to reveal + extract
4186
- // the rest on the same round before continuing. Without
4187
- // this, the loop returns success with a partial bundle
4188
- // and never tries the reveal click.
4189
- const MASKED_HINT = /\b(?:masked|hidden|bullets?|asterisks?|••+|\*{3,}|reveal|unmask)\b/i;
4190
- if (MASKED_HINT.test(nextStep.reason)) {
4191
- try {
4192
- const revealRes = await this.browser.revealMaskedCredentials();
4193
- args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: reveal pass clicked=${revealRes.clicked} diagnostic=[${revealRes.diagnostic.join("; ")}]`);
4194
- if (revealRes.clicked > 0) {
4195
- const labeledAfter = await this.extractFromDomProximity();
4196
- const newKeys = Object.keys(labeledAfter).filter((k) => credentials[k] === undefined);
4197
- if (newKeys.length > 0) {
4198
- for (const k of newKeys)
4199
- credentials[k] = labeledAfter[k];
4200
- args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: post-reveal DOM-proximity extracted ${newKeys.length} more (${newKeys.join(", ")})`);
4201
- }
4202
- else {
4203
- // Surface ALL labeled candidates we found, so
4204
- // we can see whether the value is on-page but
4205
- // mislabeled vs. genuinely not surfaced.
4206
- const allLabeled = await this.browser.extractLabeledCredentialCandidates();
4207
- const summary = allLabeled
4208
- .filter((c) => !c.isMasked)
4209
- .slice(0, 8)
4210
- .map((c) => `${c.value.slice(0, 6)}…(${c.value.length}ch)/${c.label ?? "no-label"}`)
4211
- .join(", ");
4212
- args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: post-reveal had ${allLabeled.length} candidates; visible: ${summary}`);
4213
- }
4214
- }
4553
+ // rc.16 record that the planner has now affirmatively
4554
+ // asked to extract from the current page. The top-of-iter
4555
+ // early-exit consults this to distinguish "api_key came
4556
+ // from a hidden field on a billing page" (don't exit) from
4557
+ // "api_key came from a labeled credential row the planner
4558
+ // just observed" (safe to exit on single-cred services).
4559
+ plannerExtractEmitted = true;
4560
+ // 0.8.2-rc.12 — multi-cred preservation + always-on Phase E.
4561
+ //
4562
+ // Pre-rc.12 the extract step was a tower of "if no api_key,
4563
+ // try Phase E; else done." That short-circuit silently lost
4564
+ // cloud_name + api_secret on Cloudinary-class services whose
4565
+ // api_key is plain-visible to the legacy regex extractor —
4566
+ // the legacy path filled credentials.api_key, the if-branch
4567
+ // skipped Phase E entirely, and the loop's top-of-iter exit
4568
+ // returned a partial bundle.
4569
+ //
4570
+ // New shape: run the legacy extractor, Phase E, the reveal
4571
+ // pass, and DOM-proximity UNCONDITIONALLY on every extract
4572
+ // round, merging each into `credentials` first-wins. A later
4573
+ // pass never clobbers a value an earlier pass labeled. This
4574
+ // mirrors the design doc: Phase E is the multi-cred surface;
4575
+ // single-cred is just multi-cred-with-one-key.
4576
+ const [pageText, inputValues] = await Promise.all([
4577
+ this.browser.extractText().catch(() => ""),
4578
+ this.browser.extractAllInputValues().catch(() => []),
4579
+ ]);
4580
+ const verifySource = pageText + "\n" + inputValues.join("\n");
4581
+ // Tier 1 legacy single-cred extractor (api_key by shape).
4582
+ // Merge into the running accumulator instead of overwriting;
4583
+ // a Phase E label captured on a prior round wins over a
4584
+ // later legacy regex hit.
4585
+ const legacy = await this.extractCredentials();
4586
+ for (const [k, v] of Object.entries(legacy)) {
4587
+ if (credentials[k] === undefined)
4588
+ credentials[k] = v;
4589
+ }
4590
+ // Tier 2 — Phase E labeled-token parser over the planner's
4591
+ // reason. Picks up cloud_name='dlq4xgrca' / api_key='4917…'
4592
+ // / application_id='X' / admin_api_key='…' style narrative.
4593
+ const labeled = extractAllLabeledTokensFromReason(nextStep.reason, verifySource);
4594
+ const labeledNewKeys = Object.keys(labeled).filter((k) => credentials[k] === undefined);
4595
+ if (labeledNewKeys.length > 0) {
4596
+ for (const k of labeledNewKeys)
4597
+ credentials[k] = labeled[k];
4598
+ const summary = labeledNewKeys
4599
+ .map((k) => `${k}=${labeled[k].slice(0, 4)}…${labeled[k].slice(-4)}`)
4600
+ .join(", ");
4601
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: Phase E surfaced ${labeledNewKeys.length} labeled credential(s) (${summary})`);
4602
+ }
4603
+ // Tier 2.5 — reveal-then-extract when the planner explicitly
4604
+ // flagged a masked credential. Fires whether or not we
4605
+ // already have other credentials — Cloudinary's api_secret
4606
+ // sits beside an already-visible api_key in the table.
4607
+ const MASKED_HINT = /\b(?:masked|hidden|bullets?|asterisks?|••+|\*{3,}|reveal|unmask)\b/i;
4608
+ if (MASKED_HINT.test(nextStep.reason)) {
4609
+ try {
4610
+ const revealRes = await this.browser.revealMaskedCredentials();
4611
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: reveal pass clicked=${revealRes.clicked} diagnostic=[${revealRes.diagnostic.join("; ")}]`);
4612
+ if (revealRes.clicked > 0) {
4613
+ const labeledAfter = await this.extractFromDomProximity();
4614
+ const afterNewKeys = Object.keys(labeledAfter).filter((k) => credentials[k] === undefined);
4615
+ if (afterNewKeys.length > 0) {
4616
+ for (const k of afterNewKeys)
4617
+ credentials[k] = labeledAfter[k];
4618
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: post-reveal DOM-proximity extracted ${afterNewKeys.length} more (${afterNewKeys.join(", ")})`);
4215
4619
  }
4216
- catch (err) {
4217
- args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: reveal pass error (${err instanceof Error ? err.message : String(err)})`);
4620
+ else {
4621
+ // Diagnostic: which candidates were seen on the page?
4622
+ // Helps debug "Reveal click landed but the value
4623
+ // didn't appear in proximity to a known label".
4624
+ const allLabeled = await this.browser.extractLabeledCredentialCandidates();
4625
+ const candSummary = allLabeled
4626
+ .filter((c) => !c.isMasked)
4627
+ .slice(0, 8)
4628
+ .map((c) => `${c.value.slice(0, 6)}…(${c.value.length}ch)/${c.label ?? "no-label"}`)
4629
+ .join(", ");
4630
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: post-reveal had ${allLabeled.length} candidates; visible: ${candSummary}`);
4218
4631
  }
4219
4632
  }
4220
- consecutiveFailedExtracts = 0;
4221
- continue;
4222
- }
4223
- const quoted = extractQuotedTokenFromReason(nextStep.reason, verifySource);
4224
- if (quoted !== null) {
4225
- credentials = { ...credentials, api_key: quoted };
4226
- args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted token via ` +
4227
- `planner-quoted fallback (${quoted.slice(0, 4)}…${quoted.slice(-4)})`);
4228
- consecutiveFailedExtracts = 0;
4229
- continue;
4230
- }
4231
- // Tier 4 — DOM-proximity labeled credential extraction.
4232
- // Run BEFORE bailing the extract. Walks the visible DOM,
4233
- // finds credential-shape strings, pairs each with its
4234
- // nearest credential-label text by Euclidean center
4235
- // distance. Catches multi-cred pages where the planner
4236
- // mentioned ONE value but the DOM shows several (the
4237
- // planner's narrative-style extract reason missed the
4238
- // sibling labels). Also tries to unmask hidden secrets
4239
- // first by clicking visible Reveal/Eye/Copy buttons.
4240
- try {
4241
- await this.browser.revealMaskedCredentials();
4242
4633
  }
4243
- catch {
4244
- // Best-effort; never block the extract pass on a
4245
- // reveal-click failure.
4634
+ catch (err) {
4635
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: reveal pass error (${err instanceof Error ? err.message : String(err)})`);
4246
4636
  }
4637
+ }
4638
+ // Tier 3 — DOM-proximity labeled extractor. Walks the
4639
+ // visible DOM, pairs credential-shape strings with their
4640
+ // nearest credential-label text. Catches services whose
4641
+ // planner-reason narrative missed sibling labels but whose
4642
+ // DOM still has them as <td>/<dt> pairs.
4643
+ try {
4247
4644
  const labeledFromDom = await this.extractFromDomProximity();
4248
- const newKeys = Object.keys(labeledFromDom).filter((k) => credentials[k] === undefined);
4249
- if (newKeys.length > 0) {
4250
- for (const k of newKeys)
4645
+ const domNewKeys = Object.keys(labeledFromDom).filter((k) => credentials[k] === undefined);
4646
+ if (domNewKeys.length > 0) {
4647
+ for (const k of domNewKeys)
4251
4648
  credentials[k] = labeledFromDom[k];
4252
- const summary = newKeys
4253
- .map((k) => {
4254
- const v = labeledFromDom[k];
4255
- return `${k}=${v.slice(0, 4)}…${v.slice(-4)}`;
4256
- })
4649
+ const summary = domNewKeys
4650
+ .map((k) => `${k}=${labeledFromDom[k].slice(0, 4)}…${labeledFromDom[k].slice(-4)}`)
4257
4651
  .join(", ");
4258
- args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted ${newKeys.length} labeled credential(s) ` +
4259
- `via DOM-proximity fallback (${summary})`);
4652
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: DOM-proximity surfaced ${domNewKeys.length} more (${summary})`);
4653
+ }
4654
+ }
4655
+ catch {
4656
+ // best-effort; never abort an extract pass on DOM-proximity
4657
+ // failure (page mid-navigation etc).
4658
+ }
4659
+ // Anything found across all tiers? hasMultiCredCredentials
4660
+ // also catches non-api_key labels (cloud_name, application_id).
4661
+ if (hasAnyExtractedCredential(credentials)) {
4662
+ consecutiveFailedExtracts = 0;
4663
+ continue;
4664
+ }
4665
+ // True extract failure — every tier missed. Try the legacy
4666
+ // single-value planner-quoted fallback for services whose
4667
+ // planner prose just bare-quotes the value without a known
4668
+ // label vocabulary (Railway UUID-only, IPInfo 14-hex).
4669
+ {
4670
+ const quoted = extractQuotedTokenFromReason(nextStep.reason, verifySource);
4671
+ if (quoted !== null) {
4672
+ credentials.api_key = quoted;
4673
+ args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: extracted token via ` +
4674
+ `planner-quoted fallback (${quoted.slice(0, 4)}…${quoted.slice(-4)})`);
4260
4675
  consecutiveFailedExtracts = 0;
4261
4676
  continue;
4262
4677
  }
@@ -4324,9 +4739,6 @@ ${formatInventory(input.inventory)}`,
4324
4739
  "generate a fresh one — its full value is shown once, on creation.";
4325
4740
  }
4326
4741
  }
4327
- else {
4328
- consecutiveFailedExtracts = 0;
4329
- }
4330
4742
  }
4331
4743
  else if (nextStep.kind === "click") {
4332
4744
  await this.browser.click(nextStep.selector);
@@ -4342,13 +4754,46 @@ ${formatInventory(input.inventory)}`,
4342
4754
  // services without modal-delay returns in <1s. Saves both
4343
4755
  // time (no overshoot wait) and correctness (catches the
4344
4756
  // modal-render race).
4757
+ // 0.8.2-rc.12 — merge polled extract into the running
4758
+ // credentials accumulator (was previously assigned to a
4759
+ // throwaway `pollExtract` local). On modal-key reveal
4760
+ // flows (OpenRouter, Anthropic, OpenAI) the credential
4761
+ // appears only here, and the legacy assignment was lost
4762
+ // unless the next round's top-of-iter re-read just
4763
+ // happened to find it again — a flaky guarantee.
4764
+ //
4765
+ // 0.8.2-rc.15 — also poll DOM-proximity. A click that
4766
+ // reveals an api_secret next to a known label (Cloudinary
4767
+ // reveal click → api_secret becomes visible next to "API
4768
+ // Secret" text) wouldn't surface in the legacy api_key-
4769
+ // shaped regex, so a multi-cred reveal landed nothing
4770
+ // unless the explicit extract round re-fired afterward.
4345
4771
  const credentialDeadline = Date.now() + 8000;
4346
- let pollExtract = {};
4347
4772
  while (Date.now() < credentialDeadline) {
4348
4773
  await this.browser.wait(0.5);
4349
4774
  try {
4350
- pollExtract = await this.extractCredentials();
4351
- if (pollExtract.api_key !== undefined)
4775
+ const pollExtract = await this.extractCredentials();
4776
+ for (const [k, v] of Object.entries(pollExtract)) {
4777
+ if (credentials[k] === undefined)
4778
+ credentials[k] = v;
4779
+ }
4780
+ try {
4781
+ const pollLabeled = await this.extractFromDomProximity();
4782
+ for (const [k, v] of Object.entries(pollLabeled)) {
4783
+ if (credentials[k] === undefined)
4784
+ credentials[k] = v;
4785
+ }
4786
+ }
4787
+ catch {
4788
+ // DOM-proximity failure is non-fatal; we'll retry
4789
+ // the next tick or fall through to the next round.
4790
+ }
4791
+ // Early-exit when we have an api_key — most services'
4792
+ // happy path completes in <1s. Multi-cred siblings
4793
+ // (api_secret, cloud_name) keep accumulating across
4794
+ // subsequent rounds; we don't hold the inner poll for
4795
+ // them here.
4796
+ if (credentials.api_key !== undefined)
4352
4797
  break;
4353
4798
  }
4354
4799
  catch {
@@ -4433,9 +4878,24 @@ ${formatInventory(input.inventory)}`,
4433
4878
  }
4434
4879
  // Re-extract — but tolerate the page still navigating from the
4435
4880
  // step just taken; the next round settles and re-reads.
4436
- const hadCredentialsBefore = credentials.api_key !== undefined || credentials.username !== undefined;
4881
+ // 0.8.2-rc.12 MERGE into the running accumulator. The pre-
4882
+ // rc.12 unconditional assignment wiped multi-cred fields the
4883
+ // explicit extract round just accumulated (cloud_name, api_secret,
4884
+ // etc.); on the next round's top-of-iter early-exit, only the
4885
+ // legacy single api_key survived.
4886
+ // 0.8.2-rc.12 — count distinct credential keys before re-extract
4887
+ // so the synthetic-extract trigger fires on ANY new key, not just
4888
+ // the legacy api_key / username pair. A cloudinary reveal click
4889
+ // can produce a fresh api_secret while api_key was already set;
4890
+ // the pre-rc.12 trigger silently skipped the synthetic capture
4891
+ // and the synthesizer then rejected on no_extract_step.
4892
+ const credCountBefore = Object.keys(credentials).filter((k) => !NON_CREDENTIAL_KEYS.has(k)).length;
4437
4893
  try {
4438
- credentials = await this.extractCredentials();
4894
+ const reExtract = await this.extractCredentials();
4895
+ for (const [k, v] of Object.entries(reExtract)) {
4896
+ if (credentials[k] === undefined)
4897
+ credentials[k] = v;
4898
+ }
4439
4899
  }
4440
4900
  catch {
4441
4901
  // page mid-navigation — next round's waitForFormReady handles it
@@ -4453,8 +4913,8 @@ ${formatInventory(input.inventory)}`,
4453
4913
  // RIGHT NOW (the action just ran, the token row is now visible).
4454
4914
  // Best-effort — a capture failure must never block returning the
4455
4915
  // credential we already have.
4456
- const haveNewCredentials = !hadCredentialsBefore &&
4457
- (credentials.api_key !== undefined || credentials.username !== undefined);
4916
+ const credCountAfter = Object.keys(credentials).filter((k) => !NON_CREDENTIAL_KEYS.has(k)).length;
4917
+ const haveNewCredentials = credCountAfter > credCountBefore;
4458
4918
  if (haveNewCredentials && nextStep.kind !== "extract") {
4459
4919
  try {
4460
4920
  const [postState, postInventory] = await Promise.all([
@@ -4503,6 +4963,39 @@ ${formatInventory(input.inventory)}`,
4503
4963
  }
4504
4964
  }
4505
4965
  }
4966
+ // 0.8.2-rc.10 — existing-account-no-extract classifier. Runs once
4967
+ // at loop exit when no credential surfaced AND no more specific
4968
+ // marker (paywall, anti-bot, stuck_loop) was already set on
4969
+ // lastPostVerifyDoneReason. The test identity
4970
+ // (methoxine@gmail.com) accumulates real signups across batches;
4971
+ // re-running against the same vendor lands the bot on an
4972
+ // authenticated dashboard whose API-keys page shows a masked
4973
+ // pre-existing key it cannot reveal (most vendors only show the
4974
+ // key value once at create-time). Reporting these as
4975
+ // oauth_onboarding_failed is misleading — the bot did navigate
4976
+ // correctly, the state is just unrecoverable for this identity.
4977
+ const alreadyClassified = this.lastPostVerifyDoneReason !== null &&
4978
+ this.lastPostVerifyDoneReason.startsWith("[");
4979
+ if (credentials.api_key === undefined &&
4980
+ credentials.username === undefined &&
4981
+ !alreadyClassified) {
4982
+ try {
4983
+ const finalState = await this.browser.getState();
4984
+ const finalText = await this.browser.extractText().catch(() => "");
4985
+ if (detectExistingAccountNoExtract({
4986
+ url: finalState.url,
4987
+ pageText: finalText,
4988
+ lastPlannerReason: this.lastPostVerifyDoneReason ?? "",
4989
+ })) {
4990
+ this.lastPostVerifyDoneReason =
4991
+ `[existing_account_no_extract] at ${finalState.url}; latest planner reason: ${this.lastPostVerifyDoneReason ?? "(none — loop exhausted)"}`;
4992
+ args.steps.push("Post-verify: classified as existing_account_no_extract — masked pre-existing key on an authenticated dashboard.");
4993
+ }
4994
+ }
4995
+ catch {
4996
+ // best-effort classifier — never block returning the (empty) credentials
4997
+ }
4998
+ }
4506
4999
  return credentials;
4507
5000
  }
4508
5001
  // Sign in with the credentials created during signup, so the
@@ -4602,17 +5095,30 @@ Strategy:
4602
5095
  return "extract" for a masked key, and do not return "extract" twice
4603
5096
  in a row. Instead click "Create API Key" / "New API Key" / "Generate"
4604
5097
  to make a fresh key, then extract its full value.
4605
- - **REVEAL-CLICK BEFORE EXTRACT** — when a credential is shown masked
4606
- (•••••, asterisks, dots) AND there is a VISIBLE "Show", "Reveal",
4607
- "Eye", or eye-icon button NEXT TO IT (typically same row in a
4608
- credentials table Cloudinary, Twilio, Stripe all follow this
4609
- pattern for api_secret / auth_token / secret_key), emit a CLICK
4610
- on that show/reveal button FIRST. Do NOT return extract on the same
4611
- round as the masked display — the masked text would be parsed as
4612
- the value. Next round the value will be visible and your extract
4613
- step can quote it. The bot's reveal-pass is a fallback; explicit
4614
- clicks via the planner are more reliable because you can see the
4615
- exact button in the screenshot.
5098
+ - **PARTIAL MULTI-CRED EXTRACT IS BETTER THAN ZERO** — on a multi-
5099
+ cred page where some credentials are visible and others are masked
5100
+ behind a Reveal button, return {"kind":"extract"} NOW for the
5101
+ visible labels (the bot's labeled extractor folds them into the
5102
+ credentials bundle) AND in the same reason field flag the masked
5103
+ credential so the bot's automatic reveal pass fires. Example
5104
+ reason for Cloudinary: "cloud_name='dlq4xgrca' and
5105
+ api_key='491741466469613' are visible in the table; api_secret is
5106
+ hidden behind a Reveal button please unmask." The masked
5107
+ credential's label MUST appear with one of the trigger words
5108
+ (masked / hidden / reveal / unmask / bullets / asterisks) so the
5109
+ reveal pass triggers. Do this BEFORE attempting any explicit
5110
+ reveal click — getting the visible values into the bundle first
5111
+ means a failed reveal click only loses the masked credential, not
5112
+ the visible ones too.
5113
+ - **REVEAL-CLICK AS A FALLBACK** — when the page has ONLY a masked
5114
+ credential (no visible siblings) AND there is a VISIBLE "Show",
5115
+ "Reveal", "Eye", or eye-icon button next to it, emit a CLICK on
5116
+ that button. If a previous reveal click had no effect (the page's
5117
+ inventory and screenshot look identical), do NOT keep retrying —
5118
+ emit {"kind":"extract"} anyway: the bot's labeled extractor will
5119
+ capture whatever IS visible (even if just a cloud_name with no
5120
+ api_secret) and return the partial bundle to the caller, which is
5121
+ more useful than five wasted rounds of clicking a dead reveal.
4616
5122
  - To reach API keys, prefer a {"kind":"navigate"} straight to the
4617
5123
  service's API-keys settings URL — note these usually live under the
4618
5124
  user/ACCOUNT settings, not a project or workspace's settings.