@trusty-squire/mcp 0.6.14-rc.2 → 0.6.14-rc.21
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/LICENSE +21 -0
- package/dist/api-client.d.ts +45 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +43 -0
- package/dist/api-client.js.map +1 -1
- package/dist/bin.js +12 -0
- package/dist/bin.js.map +1 -1
- package/dist/bot/agent.d.ts +35 -2
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +525 -38
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +8 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +193 -20
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/index.d.ts +4 -2
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +17 -3
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/llm-client.d.ts +1 -1
- package/dist/bot/llm-client.d.ts.map +1 -1
- package/dist/bot/onboarding-capture.d.ts +3 -0
- package/dist/bot/onboarding-capture.d.ts.map +1 -1
- package/dist/bot/onboarding-capture.js +70 -5
- package/dist/bot/onboarding-capture.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +2 -1
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +214 -29
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +4 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +300 -3
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/install/cli.d.ts +16 -0
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +63 -6
- package/dist/install/cli.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +15 -5
- package/dist/session.js.map +1 -1
- package/dist/skill-cli/cli.d.ts +25 -0
- package/dist/skill-cli/cli.d.ts.map +1 -1
- package/dist/skill-cli/cli.js +558 -13
- package/dist/skill-cli/cli.js.map +1 -1
- package/dist/skill-cli/registry-http.d.ts +1 -0
- package/dist/skill-cli/registry-http.d.ts.map +1 -1
- package/dist/skill-cli/registry-http.js +3 -0
- package/dist/skill-cli/registry-http.js.map +1 -1
- package/dist/skill-cli/signing.d.ts +21 -0
- package/dist/skill-cli/signing.d.ts.map +1 -0
- package/dist/skill-cli/signing.js +71 -0
- package/dist/skill-cli/signing.js.map +1 -0
- package/dist/skill-registry-client.d.ts +2 -0
- package/dist/skill-registry-client.d.ts.map +1 -1
- package/dist/skill-registry-client.js +83 -36
- package/dist/skill-registry-client.js.map +1 -1
- package/dist/tools/extract-failures.d.ts +23 -0
- package/dist/tools/extract-failures.d.ts.map +1 -0
- package/dist/tools/extract-failures.js +108 -0
- package/dist/tools/extract-failures.js.map +1 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +6 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/provision-any.d.ts +7 -0
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +346 -45
- package/dist/tools/provision-any.js.map +1 -1
- package/package.json +16 -15
package/dist/bot/agent.js
CHANGED
|
@@ -15,6 +15,7 @@ import { saveDebugSnapshot } from "./debug.js";
|
|
|
15
15
|
import { captureOnboardingRound } from "./onboarding-capture.js";
|
|
16
16
|
import { wasRecentlyPrewarmed, recordPrewarmSuccess } from "./prewarm-cache.js";
|
|
17
17
|
import { pickLLMPair, } from "./llm-client.js";
|
|
18
|
+
import { getDomain } from "tldts";
|
|
18
19
|
// Hard cap on LLM calls per signup. A signup that runs away to 20+ calls
|
|
19
20
|
// is both expensive and almost certainly stuck in a planning loop. 15
|
|
20
21
|
// covers: 2 initial form plans, 1 re-plan pair on validation, plus 6
|
|
@@ -131,6 +132,17 @@ export function guessSignupUrl(service) {
|
|
|
131
132
|
const host = entry ?? `${slug}.com`;
|
|
132
133
|
return `https://${host}/signup`;
|
|
133
134
|
}
|
|
135
|
+
// BUG-2 GUARD — did `url` come from KNOWN_DOMAINS as a hardcoded full
|
|
136
|
+
// URL (vs the default /signup convention)? These were explicitly
|
|
137
|
+
// chosen because the default 404s and the real entry is non-obvious
|
|
138
|
+
// — e.g. Railway's /login, Cloudflare's dash.cloudflare.com/sign-up.
|
|
139
|
+
// Trust the mapping rather than falling back to a Google search.
|
|
140
|
+
// Exported for unit testing.
|
|
141
|
+
export function isKnownDomainFullUrlMatch(service, url) {
|
|
142
|
+
const slug = service.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
143
|
+
const entry = KNOWN_DOMAINS[slug];
|
|
144
|
+
return entry !== undefined && /^https?:\/\//i.test(entry) && entry === url;
|
|
145
|
+
}
|
|
134
146
|
// True when the URL is a Google search results page — used to gate
|
|
135
147
|
// the prewarm + the post-load "did we land somewhere useful?" check.
|
|
136
148
|
export function isGoogleSearchUrl(url) {
|
|
@@ -314,6 +326,39 @@ export function formatInventory(inventory) {
|
|
|
314
326
|
? `value="" (EMPTY — fill before submitting)`
|
|
315
327
|
: `value=${JSON.stringify(e.value.slice(0, 60))}`);
|
|
316
328
|
}
|
|
329
|
+
// <select> state. `value=""` is the React-defaulted-placeholder
|
|
330
|
+
// pattern (the first option's value is empty, common for
|
|
331
|
+
// "No workspace" / "Select…" / "Choose…" prompts). React Hook
|
|
332
|
+
// Form treats those fields as untouched and silently rejects
|
|
333
|
+
// submits — Railway's token-creation form was the canonical
|
|
334
|
+
// case. The planner needs the selected text and the option
|
|
335
|
+
// list to issue an explicit `select` step before clicking
|
|
336
|
+
// submit. Selectors run to end-of-line, so this annotation goes
|
|
337
|
+
// BEFORE the trailing `selector=`.
|
|
338
|
+
//
|
|
339
|
+
// rc.17: suppress the DEFAULTED marker for selects we've already
|
|
340
|
+
// selected (data-ts-touched). A successful selectOption to a
|
|
341
|
+
// value="" option leaves value=="" but the form-state is
|
|
342
|
+
// committed — without this suppression the planner would see
|
|
343
|
+
// DEFAULTED again next round and re-select indefinitely.
|
|
344
|
+
if (e.tag === "select") {
|
|
345
|
+
const selectedText = e.selectedOptionText ?? "";
|
|
346
|
+
const isDefaulted = e.value !== null && e.value !== undefined && e.value.length === 0;
|
|
347
|
+
const alreadyTouched = e.interactedThisRun === true;
|
|
348
|
+
bits.push(isDefaulted && !alreadyTouched
|
|
349
|
+
? `value="" selected=${JSON.stringify(selectedText)} (DEFAULTED — pick an explicit option before submitting)`
|
|
350
|
+
: `value=${JSON.stringify((e.value ?? "").slice(0, 60))} selected=${JSON.stringify(selectedText)}${alreadyTouched ? " (touched — already selected by bot)" : ""}`);
|
|
351
|
+
if (e.selectOptions !== null && e.selectOptions !== undefined && e.selectOptions.length > 0) {
|
|
352
|
+
const optionTexts = e.selectOptions
|
|
353
|
+
.map((o) => o.text || `(value=${JSON.stringify(o.value)})`)
|
|
354
|
+
.filter((t) => t.length > 0)
|
|
355
|
+
.slice(0, 6)
|
|
356
|
+
.map((t) => JSON.stringify(t))
|
|
357
|
+
.join(", ");
|
|
358
|
+
if (optionTexts.length > 0)
|
|
359
|
+
bits.push(`options=[${optionTexts}]`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
317
362
|
const label = e.labelText ?? e.ariaLabel;
|
|
318
363
|
if (label !== null && label !== undefined) {
|
|
319
364
|
bits.push(`label=${JSON.stringify(label)}`);
|
|
@@ -331,6 +376,120 @@ export function formatInventory(inventory) {
|
|
|
331
376
|
})
|
|
332
377
|
.join("\n");
|
|
333
378
|
}
|
|
379
|
+
// Platform-as-a-service customer-tenant suffixes that the bundled PSL
|
|
380
|
+
// in `tldts` does NOT (yet) classify as public suffixes, but functionally
|
|
381
|
+
// behave like one: every label to the left is a distinct customer site,
|
|
382
|
+
// not an extension of the platform's own brand.
|
|
383
|
+
//
|
|
384
|
+
// Without this override, `getDomain("storysite-production.up.railway.app")`
|
|
385
|
+
// returns `"railway.app"` (first label "railway") and the guard wrongly
|
|
386
|
+
// matches it to slug "railway" — which is exactly the Railway bug this
|
|
387
|
+
// guard is meant to prevent.
|
|
388
|
+
//
|
|
389
|
+
// Keep this list short: only platforms where serving arbitrary 3rd-party
|
|
390
|
+
// content on `*.<suffix>` is the platform's primary purpose. Custom-domain-
|
|
391
|
+
// only platforms (e.g. heroku custom domains) don't belong here.
|
|
392
|
+
//
|
|
393
|
+
// Order matters — most-specific first. We pick the longest suffix the
|
|
394
|
+
// hostname ends with.
|
|
395
|
+
const PLATFORM_TENANT_SUFFIXES = [
|
|
396
|
+
"up.railway.app",
|
|
397
|
+
"railway.app",
|
|
398
|
+
"vercel.app",
|
|
399
|
+
"netlify.app",
|
|
400
|
+
"pages.dev",
|
|
401
|
+
"fly.dev",
|
|
402
|
+
"onrender.com",
|
|
403
|
+
"herokuapp.com",
|
|
404
|
+
"github.io",
|
|
405
|
+
"gitlab.io",
|
|
406
|
+
"workers.dev",
|
|
407
|
+
];
|
|
408
|
+
// Treat `hostname` as if `suffix` were a public suffix: return the label
|
|
409
|
+
// immediately to the left of the suffix, lowercased. Returns null if the
|
|
410
|
+
// hostname doesn't end with the suffix.
|
|
411
|
+
function tenantLabelUnderPlatformSuffix(hostname, suffix) {
|
|
412
|
+
const lc = hostname.toLowerCase();
|
|
413
|
+
const dotSuffix = `.${suffix}`;
|
|
414
|
+
if (!lc.endsWith(dotSuffix))
|
|
415
|
+
return null;
|
|
416
|
+
const head = lc.slice(0, -dotSuffix.length);
|
|
417
|
+
if (head.length === 0)
|
|
418
|
+
return null;
|
|
419
|
+
// The tenant label is the LAST label of head (rightmost-before-suffix).
|
|
420
|
+
const parts = head.split(".");
|
|
421
|
+
return parts[parts.length - 1] ?? null;
|
|
422
|
+
}
|
|
423
|
+
// BUG-1 GUARD — does `hostname` belong to the same registered domain as
|
|
424
|
+
// `serviceSlug` (the alphanumeric squashed service name like "railway",
|
|
425
|
+
// "postmark")?
|
|
426
|
+
//
|
|
427
|
+
// Uses PSL-aware eTLD+1 (via tldts) AND a hardcoded override for
|
|
428
|
+
// platform-tenant suffixes the bundled PSL doesn't cover yet, so platform
|
|
429
|
+
// subdomains like `*.up.railway.app` and `*.vercel.app` are correctly
|
|
430
|
+
// classified as distinct customer sites.
|
|
431
|
+
//
|
|
432
|
+
// railway.com ↔ slug "railway" → MATCH
|
|
433
|
+
// docs.railway.com ↔ slug "railway" → MATCH
|
|
434
|
+
// storysite-production.up.railway.app ↔ slug "railway" → REJECT
|
|
435
|
+
// (matched by platform override —
|
|
436
|
+
// tenant label is "storysite-production",
|
|
437
|
+
// not "railway")
|
|
438
|
+
// railway.app ↔ slug "railway" → MATCH
|
|
439
|
+
// (the apex itself is the platform's
|
|
440
|
+
// own brand; only labels to the left
|
|
441
|
+
// are tenant sites)
|
|
442
|
+
// railway.io (typosquat) ↔ slug "railway" → MATCH
|
|
443
|
+
// (intentional — we can't disambiguate
|
|
444
|
+
// typosquats from TLD variants like
|
|
445
|
+
// sentry.com vs sentry.io)
|
|
446
|
+
//
|
|
447
|
+
// Empty slug → permissive (return true), preserving prior behavior when
|
|
448
|
+
// no service name was provided to findSignupLink.
|
|
449
|
+
//
|
|
450
|
+
// Exported for unit testing.
|
|
451
|
+
export function hostMatchesServiceDomain(hostname, serviceSlug) {
|
|
452
|
+
if (serviceSlug.length === 0)
|
|
453
|
+
return true;
|
|
454
|
+
const lcHost = hostname.toLowerCase();
|
|
455
|
+
// Platform-tenant override: if hostname is `*.<platform-suffix>`, the
|
|
456
|
+
// tenant label (left of the suffix) is the "site name", not the
|
|
457
|
+
// platform's brand. Pick the LONGEST matching suffix so e.g.
|
|
458
|
+
// "x.up.railway.app" picks "up.railway.app" before "railway.app".
|
|
459
|
+
let bestSuffix = null;
|
|
460
|
+
for (const sfx of PLATFORM_TENANT_SUFFIXES) {
|
|
461
|
+
if (lcHost.endsWith(`.${sfx}`) &&
|
|
462
|
+
(bestSuffix === null || sfx.length > bestSuffix.length)) {
|
|
463
|
+
bestSuffix = sfx;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (bestSuffix !== null) {
|
|
467
|
+
const tenant = tenantLabelUnderPlatformSuffix(lcHost, bestSuffix);
|
|
468
|
+
if (tenant === null)
|
|
469
|
+
return false;
|
|
470
|
+
const normalizedTenant = tenant.replace(/[^a-z0-9]/g, "");
|
|
471
|
+
return normalizedTenant === serviceSlug;
|
|
472
|
+
}
|
|
473
|
+
const registered = getDomain(lcHost);
|
|
474
|
+
if (registered === null)
|
|
475
|
+
return false;
|
|
476
|
+
// The first label of the eTLD+1 is the "site name". For railway.com
|
|
477
|
+
// that's "railway".
|
|
478
|
+
const firstLabel = registered.split(".")[0]?.toLowerCase() ?? "";
|
|
479
|
+
// Normalize: strip hyphens so "trusty-squire" matches slug "trustysquire".
|
|
480
|
+
const normalized = firstLabel.replace(/[^a-z0-9]/g, "");
|
|
481
|
+
return normalized === serviceSlug;
|
|
482
|
+
}
|
|
483
|
+
// BUG-3 GUARD — diagnostic flag for the Inventory snapshot. Stricter
|
|
484
|
+
// than detectAntiBotBlock (no "cf-turnstile" / "recaptcha" raw-HTML
|
|
485
|
+
// matches) because the previous regex false-positive matched legitimate
|
|
486
|
+
// signup pages that just embed a Turnstile/reCAPTCHA widget script.
|
|
487
|
+
// Match on visible-text patterns only.
|
|
488
|
+
//
|
|
489
|
+
// Exported for unit testing.
|
|
490
|
+
export function isAntiBotInterstitialText(visibleText) {
|
|
491
|
+
return /just a moment|verify you are human|attention required|are you a robot|checking your browser/i.test(visibleText);
|
|
492
|
+
}
|
|
334
493
|
// Recognize a full-page anti-bot interstitial that's still up. Returns
|
|
335
494
|
// the vendor name (for the status message) or null. Pattern matching
|
|
336
495
|
// on visible text rather than markers — most vendors use the same UX
|
|
@@ -351,24 +510,80 @@ export function detectAntiBotBlock(html) {
|
|
|
351
510
|
return "Imperva";
|
|
352
511
|
return null;
|
|
353
512
|
}
|
|
354
|
-
// F17 — True when the
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
513
|
+
// F17 — True when the page looks like an authenticated dashboard
|
|
514
|
+
// rather than a sign-up page. Triggers when a prior OAuth bind
|
|
515
|
+
// already linked the account and the service auto-redirects past
|
|
516
|
+
// the sign-in widget on the next visit.
|
|
517
|
+
//
|
|
518
|
+
// **Universal precondition**: no email/password/tel input visible.
|
|
519
|
+
// A true sign-up page virtually always has at least one; if any
|
|
520
|
+
// such input is present, we are NOT authenticated regardless of
|
|
521
|
+
// what other markers the page carries.
|
|
522
|
+
//
|
|
523
|
+
// **Positive signals (any one fires authentication)**:
|
|
524
|
+
// 1. Explicit nav keyword (Sign out / Log out / Dashboard /
|
|
525
|
+
// Projects / Settings / Profile / Account / Workspaces) —
|
|
526
|
+
// the canonical strict-match path. Works for Sentry,
|
|
527
|
+
// OpenRouter, Postmark, etc. — sites with a real nav bar.
|
|
528
|
+
// 2. Billing / trial widget visible ("$X.XX left", "N days left",
|
|
529
|
+
// "Trial") — these only render to authenticated users. Caught
|
|
530
|
+
// Railway's `/new` page where the only post-login marker was
|
|
531
|
+
// the "28 days or $5.00 leftTrial" button.
|
|
532
|
+
// 3. Dashboard-route URL (path contains /new, /dashboard,
|
|
533
|
+
// /projects, /account, /settings, /workspace) AND a creation
|
|
534
|
+
// CTA visible ("New project", "Create", "New <X>") — paired
|
|
535
|
+
// signal that catches sparse SPAs whose entire layout is a
|
|
536
|
+
// single create-form on a logged-in URL.
|
|
537
|
+
//
|
|
538
|
+
// rc.18: signals 2 and 3 added. Previously only signal 1 was
|
|
539
|
+
// checked; Railway's project-creation widget tripped the form-fill
|
|
540
|
+
// fallback (and a low-confidence LLM plan that filled "Empty
|
|
541
|
+
// Project" then waited for a verification email that never came).
|
|
542
|
+
export function detectAlreadySignedIn(args) {
|
|
543
|
+
const { inventory, url } = args;
|
|
544
|
+
// Precondition: any visible credential input → not authenticated.
|
|
369
545
|
const hasCredentialInput = inventory.some((e) => e.tag === "input" &&
|
|
370
546
|
(e.type === "email" || e.type === "password" || e.type === "tel"));
|
|
371
|
-
|
|
547
|
+
if (hasCredentialInput)
|
|
548
|
+
return false;
|
|
549
|
+
const visibleTextOf = (e) => `${e.visibleText ?? ""} ${e.ariaLabel ?? ""}`.trim();
|
|
550
|
+
// Signal 1 — strict nav-keyword match (the canonical Sentry-class case).
|
|
551
|
+
const AUTH_KEYWORDS = /^\s*(?:sign out|log out|dashboard|projects|settings|profile|my account|account settings|workspaces)\s*$/i;
|
|
552
|
+
if (inventory.some((e) => AUTH_KEYWORDS.test((e.visibleText ?? e.ariaLabel ?? "").trim()))) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
// Signal 2 — billing / trial widget. Patterns observed in the wild:
|
|
556
|
+
// "28 days or $5.00 leftTrial" (Railway, no separator)
|
|
557
|
+
// "Trial" (most SaaS)
|
|
558
|
+
// "$N left" / "N days left" / "remaining"
|
|
559
|
+
const BILLING = /(?:\$\d+(?:\.\d+)?\s*(?:left|remaining)|\d+\s*days?\s*(?:left|remaining|trial)|\btrial\b)/i;
|
|
560
|
+
if (inventory.some((e) => BILLING.test(visibleTextOf(e)))) {
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
// Signal 3 — dashboard-route URL + creation CTA visible.
|
|
564
|
+
// The URL gate is conservative: a path that READS as dashboard,
|
|
565
|
+
// not /login or /signup or /. Combined with a creation CTA
|
|
566
|
+
// ("New project", "Create workspace", "+ New") it pins the
|
|
567
|
+
// page as a post-login surface.
|
|
568
|
+
let dashboardyPath = false;
|
|
569
|
+
try {
|
|
570
|
+
const parsed = new URL(url);
|
|
571
|
+
dashboardyPath =
|
|
572
|
+
/\/(?:new|dashboard|projects?|account|settings|workspace|home)(?:\/|$)/i.test(parsed.pathname) && !/\/(?:signup|sign-up|register|login|sign-in|signin)/i.test(parsed.pathname);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// Malformed URL — skip URL signal.
|
|
576
|
+
}
|
|
577
|
+
if (dashboardyPath) {
|
|
578
|
+
const CREATION_CTA = /^\s*(?:\+\s*)?(?:new\s+(?:project|workspace|team|app|site|deployment|api\s*key)|create(?:\s+(?:new|a|project|workspace))?)/i;
|
|
579
|
+
if (inventory.some((e) => {
|
|
580
|
+
const t = e.visibleText ?? e.ariaLabel ?? "";
|
|
581
|
+
return CREATION_CTA.test(t.trim());
|
|
582
|
+
})) {
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return false;
|
|
372
587
|
}
|
|
373
588
|
// True when the page has no fillable text input AND no button that
|
|
374
589
|
// reads as an email-signup option — a genuinely OAuth/SSO-only
|
|
@@ -844,8 +1059,13 @@ export class SignupAgent {
|
|
|
844
1059
|
steps.push(`Dismissed cookie consent: "${dismissed}"`);
|
|
845
1060
|
}
|
|
846
1061
|
await saveDebugSnapshot(this.browser, "before-fill");
|
|
847
|
-
|
|
848
|
-
|
|
1062
|
+
// PERF: getState() (page.content + title + screenshot) and
|
|
1063
|
+
// extractInteractiveElements (DOM walk) are independent
|
|
1064
|
+
// Playwright calls — fire them in parallel.
|
|
1065
|
+
const [state, inventory] = await Promise.all([
|
|
1066
|
+
this.browser.getState(),
|
|
1067
|
+
this.buildInventory(steps, oauthCandidates),
|
|
1068
|
+
]);
|
|
849
1069
|
// OAuth-first (T6/T13 + auto-prefer): when the page carries a
|
|
850
1070
|
// "Sign in with <provider>" affordance for a provider the bot can
|
|
851
1071
|
// use, that button unconditionally outranks any form field — hand
|
|
@@ -883,8 +1103,8 @@ export class SignupAgent {
|
|
|
883
1103
|
// path entirely and route to the post-OAuth navigation loop
|
|
884
1104
|
// to find the API key — same path Sentry/OpenRouter use post-
|
|
885
1105
|
// handshake.
|
|
886
|
-
if (detectAlreadySignedIn(inventory)) {
|
|
887
|
-
steps.push("Auto-OAuth: page shows
|
|
1106
|
+
if (detectAlreadySignedIn({ inventory, url: state.url })) {
|
|
1107
|
+
steps.push("Auto-OAuth: page shows authenticated-state markers (nav keyword, billing widget, or dashboard URL + create CTA) — " +
|
|
888
1108
|
"treating as already authenticated, jumping to post-verify navigation");
|
|
889
1109
|
return { kind: "already_oauth" };
|
|
890
1110
|
}
|
|
@@ -1029,7 +1249,10 @@ export class SignupAgent {
|
|
|
1029
1249
|
steps.push(`⚠ submit click failed: ${reason}`);
|
|
1030
1250
|
return { kind: "submit_failed", reason };
|
|
1031
1251
|
}
|
|
1032
|
-
|
|
1252
|
+
// PERF: 5s was overcautious — runCaptchaGate has its own wait
|
|
1253
|
+
// for the captcha widget to render, and waitForFormReady at
|
|
1254
|
+
// the next planner iteration handles SPA settle.
|
|
1255
|
+
await this.browser.wait(2);
|
|
1033
1256
|
const postGate = await this.runCaptchaGate("Post-submit", steps);
|
|
1034
1257
|
if (postGate.blocked)
|
|
1035
1258
|
return { kind: "captcha_blocked", captchaKind: postGate.kind };
|
|
@@ -1089,7 +1312,11 @@ export class SignupAgent {
|
|
|
1089
1312
|
.replace(/\s+/g, " ")
|
|
1090
1313
|
.trim()
|
|
1091
1314
|
.slice(0, 240);
|
|
1092
|
-
|
|
1315
|
+
// BUG-3 FIX: match on user-visible text only. Previous regex
|
|
1316
|
+
// hit `cf-turnstile` / `recaptcha` / `cloudflare` in raw HTML,
|
|
1317
|
+
// false-positive-firing on legitimate signup pages that embed
|
|
1318
|
+
// a Turnstile widget script.
|
|
1319
|
+
const antiBot = isAntiBotInterstitialText(text);
|
|
1093
1320
|
steps.push(`Inventory diagnostic: title=${JSON.stringify(state.title.slice(0, 80))} ` +
|
|
1094
1321
|
`url=${state.url.slice(0, 120)} text=${JSON.stringify(text)}` +
|
|
1095
1322
|
(antiBot ? " ⚠ anti-bot interstitial detected" : ""));
|
|
@@ -1177,7 +1404,20 @@ export class SignupAgent {
|
|
|
1177
1404
|
}
|
|
1178
1405
|
return misses.length > 0 ? misses.join(", ") : null;
|
|
1179
1406
|
}
|
|
1180
|
-
|
|
1407
|
+
// Diagnostic uploader — best-effort. When set, the post-verify
|
|
1408
|
+
// loop uploads the current DOM + screenshot to the registry-api
|
|
1409
|
+
// after a failed extract pass, so UI-shape regressions can be
|
|
1410
|
+
// diagnosed without users needing to configure debug env vars.
|
|
1411
|
+
// Wired from the MCP layer; undefined in unit-test contexts.
|
|
1412
|
+
extractFailureUploader;
|
|
1413
|
+
// Per-round telemetry uploader (0.6.14-rc.11). Fires on every post-
|
|
1414
|
+
// verify round so the registry has the full DOM + screenshot trail
|
|
1415
|
+
// for any stuck signup, not just the ones that fail at extract.
|
|
1416
|
+
roundUploader;
|
|
1417
|
+
// Set per-task in signup(). Lets the uploader know which service
|
|
1418
|
+
// was being provisioned without threading it through every call.
|
|
1419
|
+
currentService = "";
|
|
1420
|
+
constructor(browser, llm, opts = {}) {
|
|
1181
1421
|
this.browser = browser;
|
|
1182
1422
|
if (llm === undefined) {
|
|
1183
1423
|
this.llmPair = pickLLMPair({ preferCheap: PREFER_CHEAP_LLM });
|
|
@@ -1192,6 +1432,12 @@ export class SignupAgent {
|
|
|
1192
1432
|
// case. Tests and the MCP-Sampling future path use this.
|
|
1193
1433
|
this.llmPair = { primary: llm, premium: null };
|
|
1194
1434
|
}
|
|
1435
|
+
if (opts.extractFailureUploader !== undefined) {
|
|
1436
|
+
this.extractFailureUploader = opts.extractFailureUploader;
|
|
1437
|
+
}
|
|
1438
|
+
if (opts.roundUploader !== undefined) {
|
|
1439
|
+
this.roundUploader = opts.roundUploader;
|
|
1440
|
+
}
|
|
1195
1441
|
}
|
|
1196
1442
|
// Read-only view of how many calls landed on which backend. Exported
|
|
1197
1443
|
// through SignupResult.llm_backends so tests and ops can verify the
|
|
@@ -1303,6 +1549,10 @@ export class SignupAgent {
|
|
|
1303
1549
|
// (Google number-match etc.). Without it, the run still works —
|
|
1304
1550
|
// steps are just only visible in the final result.
|
|
1305
1551
|
const steps = task.stepsSink ?? [];
|
|
1552
|
+
// Stash the service name so the diagnostic uploader (called from
|
|
1553
|
+
// deep inside postVerifyLoop after a failed extract) can label
|
|
1554
|
+
// the snapshot without us threading task through every method.
|
|
1555
|
+
this.currentService = task.service;
|
|
1306
1556
|
const rawTimeout = Number(process.env.UNIVERSAL_BOT_RUN_TIMEOUT_MS);
|
|
1307
1557
|
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 600_000;
|
|
1308
1558
|
let timer;
|
|
@@ -1378,18 +1628,32 @@ export class SignupAgent {
|
|
|
1378
1628
|
}
|
|
1379
1629
|
steps.push(`Navigating to ${signupUrl}`);
|
|
1380
1630
|
await this.browser.goto(signupUrl);
|
|
1381
|
-
|
|
1631
|
+
// PERF: goto() awaits domcontentloaded; the subsequent
|
|
1632
|
+
// waitForFormReady in planExecuteWithRetry handles SPA settle.
|
|
1633
|
+
// No need for a blind 2s dwell here.
|
|
1382
1634
|
// When we *guessed* (no signup_url provided) and the page after
|
|
1383
1635
|
// load doesn't look like a signup page — no inputs, no OAuth
|
|
1384
1636
|
// affordance, or an obvious 404/error title — fall back to the
|
|
1385
1637
|
// search-and-find-link path. This is the safety net that lets
|
|
1386
1638
|
// the bot recover from a wrong canonical guess (e.g. a service
|
|
1387
1639
|
// that uses /register or a non-`.com` TLD).
|
|
1388
|
-
|
|
1640
|
+
//
|
|
1641
|
+
// BUG-2 GUARD: when the guessed URL came from KNOWN_DOMAINS as a
|
|
1642
|
+
// full hardcoded URL (e.g. Railway → https://railway.com/login,
|
|
1643
|
+
// Cloudflare → https://dash.cloudflare.com/sign-up), trust the
|
|
1644
|
+
// mapping. These were explicitly chosen because the default
|
|
1645
|
+
// /signup path 404s and the real entry is non-obvious — falling
|
|
1646
|
+
// back to a Google search has produced cross-domain bugs (the
|
|
1647
|
+
// Railway run that ended up on storysite-production.up.railway.app).
|
|
1648
|
+
const usedKnownFullUrl = isKnownDomainFullUrlMatch(task.service, guessed);
|
|
1649
|
+
if (task.signupUrl === undefined &&
|
|
1650
|
+
!usedKnownFullUrl &&
|
|
1651
|
+
!(await this.looksLikeSignupPage())) {
|
|
1389
1652
|
steps.push(`${guessed} didn't look like a signup page — searching for the real one`);
|
|
1390
1653
|
const fallbackSearch = `https://www.google.com/search?q=${encodeURIComponent(`${task.service} signup`)}`;
|
|
1391
1654
|
await this.browser.goto(fallbackSearch);
|
|
1392
|
-
|
|
1655
|
+
// PERF: domcontentloaded from goto() + findSignupLink reads
|
|
1656
|
+
// the DOM itself — no blind dwell needed.
|
|
1393
1657
|
signupUrl = fallbackSearch;
|
|
1394
1658
|
}
|
|
1395
1659
|
if (signupUrl !== guessed || isGoogleSearchUrl(signupUrl)) {
|
|
@@ -1401,7 +1665,26 @@ export class SignupAgent {
|
|
|
1401
1665
|
await this.runPrewarm(found, steps);
|
|
1402
1666
|
steps.push(`Found signup link: ${found}`);
|
|
1403
1667
|
await this.browser.goto(found);
|
|
1404
|
-
|
|
1668
|
+
// PERF: planner loop's waitForFormReady is next; no dwell.
|
|
1669
|
+
}
|
|
1670
|
+
else {
|
|
1671
|
+
// BUG-1 GUARD: findSignupLink filters off-domain candidates
|
|
1672
|
+
// (registered-domain match against the service slug). If
|
|
1673
|
+
// nothing remained AND we'd been sent here from a Google
|
|
1674
|
+
// fallback, the bot is sitting on a SERP with no usable
|
|
1675
|
+
// destination — abort rather than let the form-fill planner
|
|
1676
|
+
// happily fill the Google search box.
|
|
1677
|
+
if (isGoogleSearchUrl(signupUrl)) {
|
|
1678
|
+
return {
|
|
1679
|
+
success: false,
|
|
1680
|
+
error: `no_signup_link: searched for ${task.service}'s signup page and ` +
|
|
1681
|
+
`found no on-domain candidates. The service likely doesn't have ` +
|
|
1682
|
+
`a public self-serve signup, or the bot's domain guard rejected ` +
|
|
1683
|
+
`every match. Sign up manually.`,
|
|
1684
|
+
steps,
|
|
1685
|
+
...this.resultTail(),
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1405
1688
|
}
|
|
1406
1689
|
}
|
|
1407
1690
|
// Steps 2-5: plan the form, fill it, submit — via the
|
|
@@ -1543,7 +1826,10 @@ export class SignupAgent {
|
|
|
1543
1826
|
if (verifyLink !== null) {
|
|
1544
1827
|
steps.push(`Following verification link: ${verifyLink}`);
|
|
1545
1828
|
await this.browser.goto(verifyLink);
|
|
1546
|
-
|
|
1829
|
+
// PERF: a 1s settle is enough for the verify landing
|
|
1830
|
+
// page to commit cookies + render the post-verify
|
|
1831
|
+
// dashboard. Previous 3s was over-cautious.
|
|
1832
|
+
await this.browser.wait(1);
|
|
1547
1833
|
await saveDebugSnapshot(this.browser, "after-verify");
|
|
1548
1834
|
// Try extracting first — many services drop the API key
|
|
1549
1835
|
// straight onto the landing page after verification.
|
|
@@ -1924,7 +2210,7 @@ Output rules:
|
|
|
1924
2210
|
7-15 char handle.`;
|
|
1925
2211
|
const hintLine = input.hint !== undefined ? `\nHint: ${input.hint}` : "";
|
|
1926
2212
|
const userBlocks = [
|
|
1927
|
-
{ kind: "image", media_type: "image/
|
|
2213
|
+
{ kind: "image", media_type: "image/jpeg", data_base64: input.screenshot },
|
|
1928
2214
|
{
|
|
1929
2215
|
kind: "text",
|
|
1930
2216
|
text: `Service: ${input.service}
|
|
@@ -2046,8 +2332,11 @@ ${formatInventory(input.inventory)}`,
|
|
|
2046
2332
|
let state;
|
|
2047
2333
|
let inventory;
|
|
2048
2334
|
try {
|
|
2049
|
-
|
|
2050
|
-
inventory = await
|
|
2335
|
+
// PERF: parallel getState + inventory (independent calls).
|
|
2336
|
+
[state, inventory] = await Promise.all([
|
|
2337
|
+
this.browser.getState(),
|
|
2338
|
+
this.buildInventory(args.steps, undefined, 80),
|
|
2339
|
+
]);
|
|
2051
2340
|
}
|
|
2052
2341
|
catch (err) {
|
|
2053
2342
|
args.steps.push(`Post-verify round ${round}: page was mid-navigation ` +
|
|
@@ -2090,10 +2379,12 @@ ${formatInventory(input.inventory)}`,
|
|
|
2090
2379
|
continue;
|
|
2091
2380
|
}
|
|
2092
2381
|
args.steps.push(`Post-verify ${round + 1}/${args.maxRounds}: ${nextStep.kind} — ${nextStep.reason}`);
|
|
2093
|
-
//
|
|
2094
|
-
//
|
|
2095
|
-
//
|
|
2096
|
-
//
|
|
2382
|
+
// Dump this round's real page state + inventory in the E1
|
|
2383
|
+
// eval-corpus format so onboarding adapters can be iterated
|
|
2384
|
+
// offline without re-running the rate-limited OAuth handshake.
|
|
2385
|
+
// Default-on as of 0.6.14-rc.11 — writes to
|
|
2386
|
+
// ~/.trusty-squire/corpus/onboarding/ unless an env override
|
|
2387
|
+
// points elsewhere or disables it.
|
|
2097
2388
|
captureOnboardingRound({
|
|
2098
2389
|
service: args.service,
|
|
2099
2390
|
round,
|
|
@@ -2102,6 +2393,34 @@ ${formatInventory(input.inventory)}`,
|
|
|
2102
2393
|
inventory,
|
|
2103
2394
|
observed: nextStep,
|
|
2104
2395
|
});
|
|
2396
|
+
// Per-round telemetry upload (rc.11). Mirrors the disk capture
|
|
2397
|
+
// but ships to the registry so debugging works from any host —
|
|
2398
|
+
// the bot may be running in Goose or a sibling agent that
|
|
2399
|
+
// doesn't share a filesystem with whoever's diagnosing the run.
|
|
2400
|
+
// Fire-and-forget; failures must never abort the loop.
|
|
2401
|
+
if (this.roundUploader !== undefined) {
|
|
2402
|
+
const observedReason = "reason" in nextStep ? nextStep.reason : "";
|
|
2403
|
+
void (async () => {
|
|
2404
|
+
try {
|
|
2405
|
+
await this.roundUploader({
|
|
2406
|
+
service: args.service,
|
|
2407
|
+
round,
|
|
2408
|
+
kind: nextStep.kind,
|
|
2409
|
+
url: state.url,
|
|
2410
|
+
title: state.title,
|
|
2411
|
+
inventory_count: inventory.length,
|
|
2412
|
+
observed_reason: observedReason,
|
|
2413
|
+
html: state.html,
|
|
2414
|
+
...(state.screenshot !== undefined && state.screenshot.length > 0
|
|
2415
|
+
? { screenshot_jpeg_base64: state.screenshot }
|
|
2416
|
+
: {}),
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
catch {
|
|
2420
|
+
// best-effort — telemetry upload is diagnostic, never load-bearing
|
|
2421
|
+
}
|
|
2422
|
+
})();
|
|
2423
|
+
}
|
|
2105
2424
|
// Stuck-loop detector. Re-planning steps (done/extract/login/
|
|
2106
2425
|
// wait/navigate) are exempt: extract is its own progress signal,
|
|
2107
2426
|
// navigate intentionally changes the URL not the current DOM,
|
|
@@ -2141,6 +2460,40 @@ ${formatInventory(input.inventory)}`,
|
|
|
2141
2460
|
const emptyInputHint = emptyInputs.length > 0
|
|
2142
2461
|
? `\n\nVisible empty inputs on this page (any of these is a likely required field):\n${emptyInputs.join("\n")}\n\nIssue {"kind":"fill"} on one of them with a sensible value.`
|
|
2143
2462
|
: "";
|
|
2463
|
+
// Defaulted <select>s — value="" means the first <option>
|
|
2464
|
+
// (typically "Select…", "No workspace", "Choose…") is still
|
|
2465
|
+
// showing. React Hook Form treats those as untouched and
|
|
2466
|
+
// silently rejects submits. The Railway token-create form
|
|
2467
|
+
// was the canonical case: the Workspace dropdown's "No
|
|
2468
|
+
// workspace" placeholder was visually selected, but its
|
|
2469
|
+
// value="" left React state undefined, so Create did
|
|
2470
|
+
// nothing. Surface them explicitly so the planner emits a
|
|
2471
|
+
// select step before another click.
|
|
2472
|
+
const defaultedSelects = inventory
|
|
2473
|
+
.filter((e) => e.tag === "select" &&
|
|
2474
|
+
e.value !== null &&
|
|
2475
|
+
e.value !== undefined &&
|
|
2476
|
+
e.value.length === 0 &&
|
|
2477
|
+
e.selectOptions !== null &&
|
|
2478
|
+
e.selectOptions !== undefined &&
|
|
2479
|
+
e.selectOptions.length > 1 &&
|
|
2480
|
+
// rc.17 — skip selects we've already touched; their
|
|
2481
|
+
// form state is committed even though the visible
|
|
2482
|
+
// value="" still trips the DEFAULTED heuristic.
|
|
2483
|
+
e.interactedThisRun !== true)
|
|
2484
|
+
.slice(0, 5)
|
|
2485
|
+
.map((e) => {
|
|
2486
|
+
const label = e.labelText ?? e.ariaLabel ?? e.name ?? e.placeholder ?? "(no label)";
|
|
2487
|
+
// Show the first non-empty-value option as the suggested
|
|
2488
|
+
// pick — the obvious target when the planner doesn't
|
|
2489
|
+
// have a domain reason to prefer a specific one.
|
|
2490
|
+
const realOptions = (e.selectOptions ?? []).filter((o) => o.value.length > 0 && o.text.length > 0);
|
|
2491
|
+
const firstReal = realOptions[0]?.text ?? "(none)";
|
|
2492
|
+
return ` - ${JSON.stringify(label)} → selector=${e.selector} (first real option: ${JSON.stringify(firstReal)})`;
|
|
2493
|
+
});
|
|
2494
|
+
const defaultedSelectHint = defaultedSelects.length > 0
|
|
2495
|
+
? `\n\nVisible DEFAULTED dropdowns on this page (value="" — React form-state likely treats these as UNTOUCHED, which silently fails submit):\n${defaultedSelects.join("\n")}\n\nIssue {"kind":"select", "option_text":"…"} to commit a choice. Even if the default visible label ("No workspace", "None") is what you want, you MUST emit the select step to register it with the form's state.`
|
|
2496
|
+
: "";
|
|
2144
2497
|
args.steps.push(sameSelector
|
|
2145
2498
|
? `Post-verify: no-progress detected — same ${nextStep.kind} on same selector, inventory unchanged. Re-planning instead of re-running.`
|
|
2146
2499
|
: `Post-verify: no-progress detected — successive click steps with no inventory change. Forcing a non-click action.`);
|
|
@@ -2152,7 +2505,8 @@ ${formatInventory(input.inventory)}`,
|
|
|
2152
2505
|
`DIFFERENT KIND: {"kind":"fill"} on any empty text input, {"kind":"check"} on ` +
|
|
2153
2506
|
`any unticked checkbox, {"kind":"select"} on any unselected dropdown, or ` +
|
|
2154
2507
|
`{"kind":"done"} if there is genuinely nothing to do.` +
|
|
2155
|
-
emptyInputHint
|
|
2508
|
+
emptyInputHint +
|
|
2509
|
+
defaultedSelectHint;
|
|
2156
2510
|
prevSignature = signature;
|
|
2157
2511
|
prevInventorySize = inventory.length;
|
|
2158
2512
|
continue;
|
|
@@ -2175,6 +2529,33 @@ ${formatInventory(input.inventory)}`,
|
|
|
2175
2529
|
credentials = await this.extractCredentials();
|
|
2176
2530
|
if (credentials.api_key === undefined) {
|
|
2177
2531
|
consecutiveFailedExtracts += 1;
|
|
2532
|
+
// Best-effort diagnostic upload: when extract returns
|
|
2533
|
+
// null despite the planner asserting a credential is
|
|
2534
|
+
// visible, capture the DOM + screenshot so the UI shape
|
|
2535
|
+
// can be inspected later. Wrapped tight — any failure
|
|
2536
|
+
// here MUST NOT abort the post-verify loop.
|
|
2537
|
+
if (this.extractFailureUploader !== undefined) {
|
|
2538
|
+
void (async () => {
|
|
2539
|
+
try {
|
|
2540
|
+
const snapshot = await this.browser.getState();
|
|
2541
|
+
const candidates = await this.browser.extractCredentialCandidates();
|
|
2542
|
+
await this.extractFailureUploader({
|
|
2543
|
+
service: this.currentService,
|
|
2544
|
+
url: snapshot.url,
|
|
2545
|
+
title: snapshot.title,
|
|
2546
|
+
step_label: `post-verify round ${round + 1}/${args.maxRounds}: extract`,
|
|
2547
|
+
extract_reason: nextStep.reason,
|
|
2548
|
+
candidates,
|
|
2549
|
+
html: snapshot.html,
|
|
2550
|
+
screenshot_jpeg_base64: snapshot.screenshot,
|
|
2551
|
+
});
|
|
2552
|
+
args.steps.push(`Diagnostic: uploaded extract-failure snapshot (post-verify round ${round + 1}).`);
|
|
2553
|
+
}
|
|
2554
|
+
catch {
|
|
2555
|
+
// Silent — diagnostic uploads are best-effort.
|
|
2556
|
+
}
|
|
2557
|
+
})();
|
|
2558
|
+
}
|
|
2178
2559
|
// Two consecutive failed extracts on a DOM the planner
|
|
2179
2560
|
// keeps quoting a token from means the value's shape is
|
|
2180
2561
|
// not in our regex library (Railway: bare UUID; some
|
|
@@ -2260,7 +2641,7 @@ ${formatInventory(input.inventory)}`,
|
|
|
2260
2641
|
}
|
|
2261
2642
|
else if (nextStep.kind === "navigate") {
|
|
2262
2643
|
await this.browser.goto(nextStep.url);
|
|
2263
|
-
|
|
2644
|
+
// PERF: next round opens with waitForFormReady; no blind dwell.
|
|
2264
2645
|
}
|
|
2265
2646
|
else if (nextStep.kind === "wait") {
|
|
2266
2647
|
await this.browser.wait(Math.min(nextStep.seconds, 15));
|
|
@@ -2288,12 +2669,74 @@ ${formatInventory(input.inventory)}`,
|
|
|
2288
2669
|
}
|
|
2289
2670
|
// Re-extract — but tolerate the page still navigating from the
|
|
2290
2671
|
// step just taken; the next round settles and re-reads.
|
|
2672
|
+
const hadCredentialsBefore = credentials.api_key !== undefined || credentials.username !== undefined;
|
|
2291
2673
|
try {
|
|
2292
2674
|
credentials = await this.extractCredentials();
|
|
2293
2675
|
}
|
|
2294
2676
|
catch {
|
|
2295
2677
|
// page mid-navigation — next round's waitForFormReady handles it
|
|
2296
2678
|
}
|
|
2679
|
+
// rc.16 — synthetic extract round capture. When the implicit
|
|
2680
|
+
// extractCredentials() above pulls a credential out of the page
|
|
2681
|
+
// *without* the planner ever having picked an `extract` step,
|
|
2682
|
+
// the for-loop's early-return at the next iteration's top fires
|
|
2683
|
+
// before any further capture is written. The chain that
|
|
2684
|
+
// auto-promote sees then has no `observed.kind === "extract"`
|
|
2685
|
+
// round, so promoteToSkill rejects with no_extract_step. Fix:
|
|
2686
|
+
// when an implicit extract just succeeded and the planner's
|
|
2687
|
+
// chosen step this round wasn't already `extract`, write a
|
|
2688
|
+
// synthetic extract round with fresh state+inventory captured
|
|
2689
|
+
// RIGHT NOW (the action just ran, the token row is now visible).
|
|
2690
|
+
// Best-effort — a capture failure must never block returning the
|
|
2691
|
+
// credential we already have.
|
|
2692
|
+
const haveNewCredentials = !hadCredentialsBefore &&
|
|
2693
|
+
(credentials.api_key !== undefined || credentials.username !== undefined);
|
|
2694
|
+
if (haveNewCredentials && nextStep.kind !== "extract") {
|
|
2695
|
+
try {
|
|
2696
|
+
const [postState, postInventory] = await Promise.all([
|
|
2697
|
+
this.browser.getState(),
|
|
2698
|
+
this.buildInventory(args.steps, undefined, 80),
|
|
2699
|
+
]);
|
|
2700
|
+
const syntheticExtract = {
|
|
2701
|
+
kind: "extract",
|
|
2702
|
+
reason: `implicit extract after ${nextStep.kind} — credentials surfaced on the page`,
|
|
2703
|
+
};
|
|
2704
|
+
captureOnboardingRound({
|
|
2705
|
+
service: args.service,
|
|
2706
|
+
round: round + 1,
|
|
2707
|
+
oauth,
|
|
2708
|
+
state: postState,
|
|
2709
|
+
inventory: postInventory,
|
|
2710
|
+
observed: syntheticExtract,
|
|
2711
|
+
});
|
|
2712
|
+
if (this.roundUploader !== undefined) {
|
|
2713
|
+
void (async () => {
|
|
2714
|
+
try {
|
|
2715
|
+
await this.roundUploader({
|
|
2716
|
+
service: args.service,
|
|
2717
|
+
round: round + 1,
|
|
2718
|
+
kind: syntheticExtract.kind,
|
|
2719
|
+
url: postState.url,
|
|
2720
|
+
title: postState.title,
|
|
2721
|
+
inventory_count: postInventory.length,
|
|
2722
|
+
observed_reason: syntheticExtract.reason,
|
|
2723
|
+
html: postState.html,
|
|
2724
|
+
...(postState.screenshot !== undefined && postState.screenshot.length > 0
|
|
2725
|
+
? { screenshot_jpeg_base64: postState.screenshot }
|
|
2726
|
+
: {}),
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
catch {
|
|
2730
|
+
// best-effort
|
|
2731
|
+
}
|
|
2732
|
+
})();
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
catch {
|
|
2736
|
+
// best-effort — synthetic capture is auto-promote plumbing,
|
|
2737
|
+
// never load-bearing for the parent signup
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2297
2740
|
}
|
|
2298
2741
|
return credentials;
|
|
2299
2742
|
}
|
|
@@ -2389,14 +2832,16 @@ ${loginGuidance}
|
|
|
2389
2832
|
- 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".
|
|
2390
2833
|
- If an Accept / Agree / Continue button is DISABLED and the page shows a ToS / agreement modal (a long scrollable block of legal text, often inside a dialog), AND there is no agreement checkbox in the inventory to tick, return {"kind":"scroll"}. Some services (Railway is the canonical case) only enable the Accept button after the user scrolls the modal body to the bottom. The bot auto-detects the scrollable container — you do NOT need a selector. Do NOT use "click" to try to scroll; "click" does not scroll, it lands a click and returns. After scrolling, the next round should re-read the page and click the now-enabled Accept button (which will appear in the inventory).
|
|
2391
2834
|
- 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.
|
|
2835
|
+
- **Token names must be unique within the account.** Many services (Railway is the canonical case) silently reject submits whose name collides with an existing token — the click registers, the button takes focus, but no token is created and no error toast is shown. Before filling a token-name input, READ the visible existing-tokens list on the page (names like "mykey", "mytoken123", any others). For the name you fill, prefer a fresh unique name like \`ts-<random>\` or \`agent-<short-suffix>\`; NEVER reuse a name that appears in the existing list — including names with sequential suffixes like \`mykey2\`, \`mykey3\` if the un-suffixed name is also present (assume the user has been iterating). If you cannot see the existing-tokens list (it scrolled off, the page hides it), pick a name with high entropy (8+ random alphanumeric chars).
|
|
2392
2836
|
- On a token-creation form whose permission/scope dropdowns default to "No Access" / "None", you MUST set permissions BEFORE clicking the create button.
|
|
2837
|
+
- **Defaulted dropdowns (value="") gate submit, even when the visible label looks fine.** An inventory line marked \`(DEFAULTED — pick an explicit option before submitting)\` means a \`<select>\` is showing its first option visually but its underlying value is empty. React-form-state libraries (React Hook Form, Formik) treat those as UNTOUCHED and reject submits silently — the click on the submit button visually focuses it but no submission occurs. Issue \`{"kind":"select", "option_text":"…"}\` to commit a choice BEFORE clicking submit, even if the existing visible label ("No workspace", "None", "Select…") is the option you want. The Railway token-create form was the canonical case: typing the name and clicking Create did nothing for six rounds because the Workspace dropdown was never explicitly selected.
|
|
2393
2838
|
- **PERMISSION SCOPE — default is MAXIMUM.** ${input.scopeHint !== undefined
|
|
2394
2839
|
? `The user provided a scope hint: "${input.scopeHint}". Pick option_text values aligned with this on each permission dropdown.`
|
|
2395
2840
|
: `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.`}
|
|
2396
2841
|
- 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.
|
|
2397
2842
|
- Round ${input.round + 1} of ${input.maxRounds}. Prefer "done" if you're not making progress.`;
|
|
2398
2843
|
const userBlocks = [
|
|
2399
|
-
{ kind: "image", media_type: "image/
|
|
2844
|
+
{ kind: "image", media_type: "image/jpeg", data_base64: input.state.screenshot },
|
|
2400
2845
|
{
|
|
2401
2846
|
kind: "text",
|
|
2402
2847
|
text: `Service: ${input.service}
|
|
@@ -2496,6 +2941,16 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
|
|
|
2496
2941
|
// Negative: signin/login/logout in host+path.
|
|
2497
2942
|
if (/(?:^|\/)(?:signin|login|logout|sign-in|log-in)\b/.test(hostPath))
|
|
2498
2943
|
continue;
|
|
2944
|
+
// BUG-1 GUARD: registered-domain match against the target service.
|
|
2945
|
+
// Without this, a Google search for "Railway signup" returned a
|
|
2946
|
+
// link to storysite-production.up.railway.app/signup/ — somebody's
|
|
2947
|
+
// hobby Django app hosted on Railway — and the bot filled out the
|
|
2948
|
+
// form, creating a junk account on the wrong website. PSL-aware
|
|
2949
|
+
// eTLD+1 comparison handles platform suffixes like .up.railway.app
|
|
2950
|
+
// and .vercel.app (where each customer subdomain is its own
|
|
2951
|
+
// "registered" entity) correctly.
|
|
2952
|
+
if (!hostMatchesServiceDomain(url.hostname, serviceSlug))
|
|
2953
|
+
continue;
|
|
2499
2954
|
// Score: a host containing the service slug is a strong match.
|
|
2500
2955
|
// Without a slug to compare against, every match scores 1.
|
|
2501
2956
|
const hostLower = url.hostname.toLowerCase();
|
|
@@ -2639,6 +3094,38 @@ ${formatInventory(input.inventory)}${input.hint !== undefined ? `\n\nIMPORTANT
|
|
|
2639
3094
|
// with whatever we had (or null).
|
|
2640
3095
|
}
|
|
2641
3096
|
}
|
|
3097
|
+
// Pass 4 — Copy-button colocation scan. Railway's "New Token"
|
|
3098
|
+
// modal shows the UUID inside a <code> built character-by-span,
|
|
3099
|
+
// which Pass 1's direct-text walk can't reassemble. Walk every
|
|
3100
|
+
// visible "Copy" affordance's ancestor subtree, tokenize its
|
|
3101
|
+
// innerText, and accept the first token that looks like a
|
|
3102
|
+
// credential. Strict on shape (length 16-256, isolated token)
|
|
3103
|
+
// to avoid false positives on copy-blog-post-link buttons.
|
|
3104
|
+
if (apiKey === null) {
|
|
3105
|
+
try {
|
|
3106
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3107
|
+
for (const candidate of await this.browser.extractCredentialsNearCopyButtons()) {
|
|
3108
|
+
// The candidate is a bare whitespace-isolated token. If it's
|
|
3109
|
+
// a UUID, accept it directly — the Copy-button colocation
|
|
3110
|
+
// is the credential signal we'd otherwise demand a textual
|
|
3111
|
+
// "api key" label for.
|
|
3112
|
+
if (UUID_RE.test(candidate)) {
|
|
3113
|
+
apiKey = candidate;
|
|
3114
|
+
break;
|
|
3115
|
+
}
|
|
3116
|
+
// Otherwise route through the normal extractor — accepts
|
|
3117
|
+
// gh*_*, sk_*, pk_*, Stripe/AWS-style prefixes, JWTs, etc.
|
|
3118
|
+
const hit = extractApiKeyFromText(candidate);
|
|
3119
|
+
if (hit !== null && !isTruncatedCapture(candidate, hit)) {
|
|
3120
|
+
apiKey = hit;
|
|
3121
|
+
break;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
catch {
|
|
3126
|
+
// Non-fatal — leave apiKey as null and fall through.
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
2642
3129
|
// Last resort: if every path returned a truncated value, persist
|
|
2643
3130
|
// it with a `_truncated` suffix so the host agent can surface the
|
|
2644
3131
|
// partial result to the user (better than reporting "no key
|