@trusty-squire/mcp 0.1.11 → 0.1.12
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.d.ts +7 -3
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +296 -150
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +28 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +231 -20
- package/dist/bot/browser.js.map +1 -1
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +14 -0
- package/dist/tools/provision-any.js.map +1 -1
- package/package.json +1 -1
package/dist/bot/agent.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BrowserController, CaptchaVariant } from "./browser.js";
|
|
1
|
+
import type { BrowserController, CaptchaVariant, InteractiveElement } from "./browser.js";
|
|
2
2
|
import { type LLMClient, type LLMPair } from "./llm-client.js";
|
|
3
3
|
export interface AgentInbox {
|
|
4
4
|
waitForEmail(input: {
|
|
@@ -95,7 +95,9 @@ export type PostVerifyStep = {
|
|
|
95
95
|
};
|
|
96
96
|
declare const FILL_VALUE_KINDS: readonly ["email", "password", "name", "username", "company", "literal"];
|
|
97
97
|
type FillValueKind = (typeof FILL_VALUE_KINDS)[number];
|
|
98
|
-
export declare function parseSignupPlan(raw: string): SignupPlan;
|
|
98
|
+
export declare function parseSignupPlan(raw: string, allowedSelectors?: ReadonlySet<string>): SignupPlan;
|
|
99
|
+
export declare function formatInventory(inventory: readonly InteractiveElement[]): string;
|
|
100
|
+
export declare function isOauthOnlyChooser(inventory: readonly InteractiveElement[]): boolean;
|
|
99
101
|
export declare function parsePostVerifyStep(raw: string): PostVerifyStep;
|
|
100
102
|
export declare function extractApiKeyFromText(text: string): string | null;
|
|
101
103
|
export declare function pickVerificationLink(links: readonly string[]): string | null;
|
|
@@ -108,6 +110,9 @@ export declare class SignupAgent {
|
|
|
108
110
|
private resultTail;
|
|
109
111
|
private runCaptchaGate;
|
|
110
112
|
private executePlan;
|
|
113
|
+
private planExecuteWithRetry;
|
|
114
|
+
private buildInventory;
|
|
115
|
+
private verifyPlan;
|
|
111
116
|
constructor(browser: BrowserController, llm?: LLMClient | LLMPair);
|
|
112
117
|
get backends(): readonly string[];
|
|
113
118
|
private callLLM;
|
|
@@ -116,7 +121,6 @@ export declare class SignupAgent {
|
|
|
116
121
|
signup(task: SignupTask): Promise<SignupResult>;
|
|
117
122
|
private runSignup;
|
|
118
123
|
private planSignupForm;
|
|
119
|
-
private extractFormHtml;
|
|
120
124
|
private looksLikeValidationFailure;
|
|
121
125
|
private pickVerificationLink;
|
|
122
126
|
private waitForVerificationEmail;
|
package/dist/bot/agent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/bot/agent.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/bot/agent.ts"],"names":[],"mappings":"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"}
|
package/dist/bot/agent.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// The "plan" is a small JSON schema Claude emits, so this file stays a thin
|
|
8
8
|
// executor; the prompt is the contract. If a service breaks we tweak the
|
|
9
9
|
// prompt rather than threading service-specific logic through the agent.
|
|
10
|
+
import { rankAndCapInventory, scoreSignupButton } from "./browser.js";
|
|
10
11
|
import { saveDebugSnapshot } from "./debug.js";
|
|
11
12
|
import { wasRecentlyPrewarmed, recordPrewarmSuccess } from "./prewarm-cache.js";
|
|
12
13
|
import { pickLLMPair, } from "./llm-client.js";
|
|
@@ -134,7 +135,7 @@ function validateAction(value, index) {
|
|
|
134
135
|
throw new Error(`action[${index}]: unknown kind ${JSON.stringify(kind)}`);
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
|
-
export function parseSignupPlan(raw) {
|
|
138
|
+
export function parseSignupPlan(raw, allowedSelectors) {
|
|
138
139
|
const obj = extractJsonObject(raw);
|
|
139
140
|
const rawActions = obj["actions"];
|
|
140
141
|
if (!Array.isArray(rawActions)) {
|
|
@@ -146,11 +147,77 @@ export function parseSignupPlan(raw) {
|
|
|
146
147
|
if (confidence !== "high" && confidence !== "medium" && confidence !== "low") {
|
|
147
148
|
throw new Error(`signup plan: invalid confidence ${JSON.stringify(confidence)}`);
|
|
148
149
|
}
|
|
150
|
+
// F3 T4: when the page inventory is supplied, every selector the
|
|
151
|
+
// planner emits must be one the bot computed and put in the
|
|
152
|
+
// inventory. This makes selector hallucination a parse-time
|
|
153
|
+
// rejection (the throw triggers a re-plan) before any DOM
|
|
154
|
+
// round-trip — and an invalid selector like `:contains()` simply
|
|
155
|
+
// isn't in the inventory, so it is caught here too.
|
|
156
|
+
if (allowedSelectors !== undefined) {
|
|
157
|
+
for (const a of actions) {
|
|
158
|
+
if (!allowedSelectors.has(a.selector)) {
|
|
159
|
+
throw new Error(`signup plan: action selector ${JSON.stringify(a.selector)} is not in the page inventory`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!allowedSelectors.has(submitSelector)) {
|
|
163
|
+
throw new Error(`signup plan: submit_selector ${JSON.stringify(submitSelector)} is not in the page inventory`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
149
166
|
const notes = typeof obj["notes"] === "string" ? obj["notes"] : undefined;
|
|
150
167
|
return notes !== undefined
|
|
151
168
|
? { actions, submit_selector: submitSelector, confidence, notes }
|
|
152
169
|
: { actions, submit_selector: submitSelector, confidence };
|
|
153
170
|
}
|
|
171
|
+
// Render the element inventory as a compact text block for the
|
|
172
|
+
// planner — one line per element, ending with the verified
|
|
173
|
+
// `selector=` the planner must copy verbatim (F3 T3).
|
|
174
|
+
export function formatInventory(inventory) {
|
|
175
|
+
if (inventory.length === 0)
|
|
176
|
+
return "(no interactive elements found on the page)";
|
|
177
|
+
return inventory
|
|
178
|
+
.map((e) => {
|
|
179
|
+
const bits = [`[${e.index}] ${e.tag}`];
|
|
180
|
+
if (e.type !== null)
|
|
181
|
+
bits.push(`type=${e.type}`);
|
|
182
|
+
if (e.name !== null)
|
|
183
|
+
bits.push(`name=${e.name}`);
|
|
184
|
+
if (e.placeholder !== null) {
|
|
185
|
+
bits.push(`placeholder=${JSON.stringify(e.placeholder)}`);
|
|
186
|
+
}
|
|
187
|
+
const label = e.labelText ?? e.ariaLabel;
|
|
188
|
+
if (label !== null && label !== undefined) {
|
|
189
|
+
bits.push(`label=${JSON.stringify(label)}`);
|
|
190
|
+
}
|
|
191
|
+
if (e.tag !== "input" &&
|
|
192
|
+
e.tag !== "textarea" &&
|
|
193
|
+
e.tag !== "select" &&
|
|
194
|
+
e.visibleText !== null) {
|
|
195
|
+
bits.push(`text=${JSON.stringify(e.visibleText)}`);
|
|
196
|
+
}
|
|
197
|
+
if (e.inConsentWidget)
|
|
198
|
+
bits.push("[cookie-consent — avoid]");
|
|
199
|
+
bits.push(`selector=${e.selector}`);
|
|
200
|
+
return bits.join(" ");
|
|
201
|
+
})
|
|
202
|
+
.join("\n");
|
|
203
|
+
}
|
|
204
|
+
// True when the page has no fillable text input AND no button that
|
|
205
|
+
// reads as an email-signup option — a genuinely OAuth/SSO-only
|
|
206
|
+
// service with no form to automate (F3 Issue 4).
|
|
207
|
+
export function isOauthOnlyChooser(inventory) {
|
|
208
|
+
const TEXTLIKE = new Set([
|
|
209
|
+
"text",
|
|
210
|
+
"email",
|
|
211
|
+
"password",
|
|
212
|
+
"tel",
|
|
213
|
+
null,
|
|
214
|
+
]);
|
|
215
|
+
const hasFillableInput = inventory.some((e) => (e.tag === "input" && TEXTLIKE.has(e.type)) || e.tag === "textarea");
|
|
216
|
+
if (hasFillableInput)
|
|
217
|
+
return false;
|
|
218
|
+
const hasEmailOption = inventory.some((e) => scoreSignupButton(`${e.visibleText ?? ""} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`) > 0);
|
|
219
|
+
return !hasEmailOption;
|
|
220
|
+
}
|
|
154
221
|
export function parsePostVerifyStep(raw) {
|
|
155
222
|
const obj = extractJsonObject(raw);
|
|
156
223
|
const kind = obj["kind"];
|
|
@@ -363,16 +430,31 @@ export class SignupAgent {
|
|
|
363
430
|
// the initial-plan and the validation re-plan paths so the step
|
|
364
431
|
// logging stays consistent (the re-plan copy historically dropped
|
|
365
432
|
// the per-action steps.push entries the initial path had).
|
|
366
|
-
async executePlan(plan, fillValues, steps) {
|
|
433
|
+
async executePlan(plan, fillValues, steps, bySelector) {
|
|
367
434
|
for (const action of plan.actions) {
|
|
435
|
+
const el = bySelector.get(action.selector);
|
|
436
|
+
// Belt-and-suspenders: the planner is told to skip
|
|
437
|
+
// cookie-consent elements; never act on one even if it slips
|
|
438
|
+
// through (Render's TOS check hit an Osano consent toggle).
|
|
439
|
+
if (el !== undefined && el.inConsentWidget) {
|
|
440
|
+
steps.push(`Skip ${action.kind} ${action.selector} — inside a cookie-consent widget`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
368
443
|
try {
|
|
369
444
|
if (action.kind === "fill") {
|
|
370
445
|
// `literal` is per-action; everything else is a fixed value.
|
|
371
446
|
const value = action.value_kind === "literal"
|
|
372
447
|
? action.literal ?? ""
|
|
373
448
|
: fillValues[action.value_kind];
|
|
374
|
-
|
|
375
|
-
|
|
449
|
+
if (el !== undefined && el.tag === "select") {
|
|
450
|
+
// A <select> needs selectOption, not type() (Sentry bug).
|
|
451
|
+
steps.push(`Select ${action.selector}`);
|
|
452
|
+
await this.browser.selectOption(action.selector);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
steps.push(`Fill ${action.value_kind} → ${action.selector}`);
|
|
456
|
+
await this.browser.type(action.selector, value);
|
|
457
|
+
}
|
|
376
458
|
}
|
|
377
459
|
else if (action.kind === "check") {
|
|
378
460
|
steps.push(`Check ${action.selector} (${action.reason})`);
|
|
@@ -389,6 +471,153 @@ export class SignupAgent {
|
|
|
389
471
|
}
|
|
390
472
|
}
|
|
391
473
|
}
|
|
474
|
+
// F3 T5: the verify-and-replan loop. Builds a DOM-grounded element
|
|
475
|
+
// inventory, has the planner pick from it, verifies the picks
|
|
476
|
+
// resolve, executes, and submits. A bad pick re-plans instead of
|
|
477
|
+
// cascading through 10s timeouts. Replan caps are split (Tension
|
|
478
|
+
// 3): a selector-miss ("the bot erred") is capped tight; a reveal
|
|
479
|
+
// click or a post-submit validation error ("the page advanced")
|
|
480
|
+
// gets more headroom. All bounded by the 15-call LLM breaker + the
|
|
481
|
+
// F2 top-level deadline.
|
|
482
|
+
async planExecuteWithRetry(task, fillValues, steps) {
|
|
483
|
+
const MAX_ERROR_REPLANS = 2;
|
|
484
|
+
const MAX_PROGRESS_REPLANS = 4;
|
|
485
|
+
let errorReplans = 0;
|
|
486
|
+
let progressReplans = 0;
|
|
487
|
+
let hint;
|
|
488
|
+
for (;;) {
|
|
489
|
+
await this.browser.waitForFormReady();
|
|
490
|
+
await saveDebugSnapshot(this.browser, "before-fill");
|
|
491
|
+
const state = await this.browser.getState();
|
|
492
|
+
const inventory = await this.buildInventory(steps);
|
|
493
|
+
// OAuth-only: no fillable input AND no button that reads as an
|
|
494
|
+
// email-signup option — nothing to automate (Issue 4).
|
|
495
|
+
if (isOauthOnlyChooser(inventory)) {
|
|
496
|
+
return { kind: "oauth_required" };
|
|
497
|
+
}
|
|
498
|
+
steps.push("Asking Claude to plan the signup form fill...");
|
|
499
|
+
let plan;
|
|
500
|
+
try {
|
|
501
|
+
plan = await this.planSignupForm({
|
|
502
|
+
service: task.service,
|
|
503
|
+
url: state.url,
|
|
504
|
+
inventory,
|
|
505
|
+
screenshot: state.screenshot,
|
|
506
|
+
...(hint !== undefined ? { hint } : {}),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
// Parse/validation failure — includes a hallucinated selector
|
|
511
|
+
// rejected by the inventory check. An error replan.
|
|
512
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
513
|
+
if (++errorReplans > MAX_ERROR_REPLANS) {
|
|
514
|
+
return { kind: "planning_failed", reason: `planner output never validated: ${reason}` };
|
|
515
|
+
}
|
|
516
|
+
steps.push(`⚠ plan rejected (${reason}) — re-planning`);
|
|
517
|
+
hint =
|
|
518
|
+
"Your previous plan used a selector not in the inventory. Use ONLY selectors copied verbatim from a `selector=` field.";
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
steps.push(`Plan: ${plan.actions.length} action(s), confidence=${plan.confidence}` +
|
|
522
|
+
(plan.notes !== undefined ? ` — ${plan.notes}` : ""));
|
|
523
|
+
// Verify the picks resolve on the live page — also catches a
|
|
524
|
+
// stale selector resolving to a recycled wrong node (Tension 4).
|
|
525
|
+
const bySelector = new Map(inventory.map((e) => [e.selector, e]));
|
|
526
|
+
const miss = await this.verifyPlan(plan, bySelector);
|
|
527
|
+
if (miss !== null) {
|
|
528
|
+
if (++errorReplans > MAX_ERROR_REPLANS) {
|
|
529
|
+
return { kind: "planning_failed", reason: `planned selectors kept missing: ${miss}` };
|
|
530
|
+
}
|
|
531
|
+
steps.push(`⚠ planned selectors did not verify (${miss}) — re-planning`);
|
|
532
|
+
hint = `These selectors did not resolve correctly: ${miss}. Pick different inventory entries.`;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
await this.executePlan(plan, fillValues, steps, bySelector);
|
|
536
|
+
// A plan with no fill actions only revealed/advanced the page (a
|
|
537
|
+
// cookie banner, a two-stage "sign up with email" chooser) — the
|
|
538
|
+
// real form should now be present. Re-extract and plan it.
|
|
539
|
+
const hadFill = plan.actions.some((a) => a.kind === "fill");
|
|
540
|
+
if (!hadFill) {
|
|
541
|
+
if (++progressReplans > MAX_PROGRESS_REPLANS) {
|
|
542
|
+
return { kind: "planning_failed", reason: "never reached a fillable form" };
|
|
543
|
+
}
|
|
544
|
+
steps.push("Plan only revealed the page — re-planning the now-visible form");
|
|
545
|
+
hint =
|
|
546
|
+
"The previous step revealed or advanced the page. Plan the signup form that should now be visible.";
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
// Captcha gate + submit.
|
|
550
|
+
const preGate = await this.runCaptchaGate("Pre-submit", steps);
|
|
551
|
+
if (preGate.blocked)
|
|
552
|
+
return { kind: "captcha_blocked", captchaKind: preGate.kind };
|
|
553
|
+
steps.push(`Submit → ${plan.submit_selector}`);
|
|
554
|
+
try {
|
|
555
|
+
await this.browser.clickSubmit(plan.submit_selector);
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
559
|
+
steps.push(`⚠ submit click failed: ${reason}`);
|
|
560
|
+
return { kind: "submit_failed", reason };
|
|
561
|
+
}
|
|
562
|
+
await this.browser.wait(5);
|
|
563
|
+
const postGate = await this.runCaptchaGate("Post-submit", steps);
|
|
564
|
+
if (postGate.blocked)
|
|
565
|
+
return { kind: "captcha_blocked", captchaKind: postGate.kind };
|
|
566
|
+
if (postGate.found && postGate.solved) {
|
|
567
|
+
// Re-click submit so the populated token ships with the form.
|
|
568
|
+
try {
|
|
569
|
+
await this.browser.click(plan.submit_selector);
|
|
570
|
+
await this.browser.wait(3);
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
steps.push(`⚠ post-captcha submit retry failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Post-submit validation errors → the page advanced; re-plan
|
|
577
|
+
// against the new state (a progress replan).
|
|
578
|
+
const afterText = (await this.browser.extractText()).slice(0, 4000);
|
|
579
|
+
if (this.looksLikeValidationFailure(afterText)) {
|
|
580
|
+
if (++progressReplans > MAX_PROGRESS_REPLANS) {
|
|
581
|
+
// Out of replan headroom — proceed; credential extraction
|
|
582
|
+
// confirms whether the signup actually went through.
|
|
583
|
+
return { kind: "submitted" };
|
|
584
|
+
}
|
|
585
|
+
steps.push("Post-submit validation errors — re-planning");
|
|
586
|
+
hint = `The previous submit produced validation errors. Visible page text: ${afterText.slice(0, 600)}`;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
return { kind: "submitted" };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Extract + rank the page's interactive elements (F3 T1/T2).
|
|
593
|
+
async buildInventory(steps) {
|
|
594
|
+
const raw = await this.browser.extractInteractiveElements();
|
|
595
|
+
const { inventory, buttonsDropped } = rankAndCapInventory(raw);
|
|
596
|
+
steps.push(`Inventory: ${inventory.length} element(s)` +
|
|
597
|
+
(buttonsDropped > 0 ? ` (${buttonsDropped} low-ranked button(s) dropped)` : ""));
|
|
598
|
+
return inventory;
|
|
599
|
+
}
|
|
600
|
+
// Verify every selector the plan references still resolves on the
|
|
601
|
+
// live page, and — when the inventory entry had an id — that it
|
|
602
|
+
// resolves to that same element (Tension 4: a stale structural
|
|
603
|
+
// selector can resolve to a recycled wrong node). Returns a
|
|
604
|
+
// human-readable miss list, or null when every selector is good.
|
|
605
|
+
async verifyPlan(plan, bySelector) {
|
|
606
|
+
const selectors = [...plan.actions.map((a) => a.selector), plan.submit_selector];
|
|
607
|
+
const misses = [];
|
|
608
|
+
for (const sel of new Set(selectors)) {
|
|
609
|
+
const info = await this.browser.inspectSelector(sel);
|
|
610
|
+
if (info.count === 0) {
|
|
611
|
+
misses.push(sel);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const inv = bySelector.get(sel);
|
|
615
|
+
if (inv !== undefined && inv.id !== null && info.id !== inv.id) {
|
|
616
|
+
misses.push(`${sel} (resolved to the wrong element)`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return misses.length > 0 ? misses.join(", ") : null;
|
|
620
|
+
}
|
|
392
621
|
constructor(browser, llm) {
|
|
393
622
|
this.browser = browser;
|
|
394
623
|
if (llm === undefined) {
|
|
@@ -587,136 +816,53 @@ export class SignupAgent {
|
|
|
587
816
|
await this.browser.wait(2);
|
|
588
817
|
}
|
|
589
818
|
}
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
// a
|
|
593
|
-
|
|
594
|
-
await this.browser.waitForFormReady();
|
|
595
|
-
// Step 2: Plan the form fill with Claude.
|
|
596
|
-
steps.push("Asking Claude to plan the signup form fill...");
|
|
597
|
-
await saveDebugSnapshot(this.browser, "before-fill");
|
|
598
|
-
const state = await this.browser.getState();
|
|
599
|
-
const plan = await this.planSignupForm({
|
|
600
|
-
service: task.service,
|
|
601
|
-
url: state.url,
|
|
602
|
-
html: state.html,
|
|
603
|
-
screenshot: state.screenshot,
|
|
604
|
-
});
|
|
605
|
-
steps.push(`Plan: ${plan.actions.length} action(s), confidence=${plan.confidence}${plan.notes !== undefined ? ` — ${plan.notes}` : ""}`);
|
|
606
|
-
// Step 3: Execute the plan.
|
|
819
|
+
// Steps 2-5: plan the form, fill it, submit — via the
|
|
820
|
+
// verify-and-replan loop (F3). The planner picks selectors from
|
|
821
|
+
// a DOM-grounded element inventory; a bad pick re-plans instead
|
|
822
|
+
// of cascading through 10s timeouts to total failure.
|
|
607
823
|
const fillValues = {
|
|
608
824
|
email: task.email,
|
|
609
825
|
password,
|
|
610
826
|
name: displayName,
|
|
611
827
|
username,
|
|
612
828
|
company: "Trusty Squire",
|
|
613
|
-
// `literal` has no fixed value — resolved per-action
|
|
829
|
+
// `literal` has no fixed value — resolved per-action.
|
|
614
830
|
literal: "",
|
|
615
831
|
};
|
|
616
|
-
await this.
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
// Many sites render the widget alongside the form on first
|
|
620
|
-
// load; failing to interact with it leaves cf-turnstile-response
|
|
621
|
-
// empty and the submit gets server-side-rejected with a generic
|
|
622
|
-
// validation error we'd waste a re-plan trying to debug.
|
|
623
|
-
const preSubmitGate = await this.runCaptchaGate("Pre-submit", steps);
|
|
624
|
-
if (preSubmitGate.blocked) {
|
|
625
|
-
return {
|
|
626
|
-
success: false,
|
|
627
|
-
error: `captcha_blocked: visible ${preSubmitGate.kind} challenge did not resolve. The site flagged this session.`,
|
|
628
|
-
steps,
|
|
629
|
-
...this.resultTail(),
|
|
630
|
-
};
|
|
631
|
-
}
|
|
632
|
-
// Step 4: Submit. clickSubmit() disambiguates when the planned
|
|
633
|
-
// selector matches several button[type=submit] (OAuth buttons are
|
|
634
|
-
// submit-typed too). A submit click that fails means the form was
|
|
635
|
-
// never submitted — fail fast here rather than fall through into
|
|
636
|
-
// the multi-minute verification-email poll for an email that can
|
|
637
|
-
// never arrive.
|
|
638
|
-
steps.push(`Submit → ${plan.submit_selector}`);
|
|
639
|
-
try {
|
|
640
|
-
await this.browser.clickSubmit(plan.submit_selector);
|
|
641
|
-
}
|
|
642
|
-
catch (err) {
|
|
643
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
644
|
-
steps.push(`⚠ submit click failed: ${reason}`);
|
|
645
|
-
return {
|
|
646
|
-
success: false,
|
|
647
|
-
error: `submit_failed: could not click the signup button — ${reason}`,
|
|
648
|
-
steps,
|
|
649
|
-
...this.resultTail(),
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
await this.browser.wait(5);
|
|
653
|
-
// Tier 2 captcha (post-submit): some services only render the
|
|
654
|
-
// challenge after form submission (deferred rendering). Same
|
|
655
|
-
// shape as the pre-submit check.
|
|
656
|
-
const postSubmitGate = await this.runCaptchaGate("Post-submit", steps);
|
|
657
|
-
if (postSubmitGate.blocked) {
|
|
658
|
-
return {
|
|
659
|
-
success: false,
|
|
660
|
-
error: `captcha_blocked: post-submit ${postSubmitGate.kind} challenge did not resolve.`,
|
|
661
|
-
steps,
|
|
662
|
-
...this.resultTail(),
|
|
663
|
-
};
|
|
664
|
-
}
|
|
665
|
-
if (postSubmitGate.found && postSubmitGate.solved) {
|
|
666
|
-
// Re-click submit so the populated token ships with the form.
|
|
667
|
-
try {
|
|
668
|
-
await this.browser.click(plan.submit_selector);
|
|
669
|
-
await this.browser.wait(3);
|
|
670
|
-
}
|
|
671
|
-
catch (err) {
|
|
672
|
-
steps.push(`⚠ post-captcha submit retry failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
await saveDebugSnapshot(this.browser, "after-submit");
|
|
676
|
-
// Step 5: Detect post-submit validation errors — if visible text contains
|
|
677
|
-
// hints like "required", "must be between", "please accept", we re-plan
|
|
678
|
-
// once with the new state. This handles the Postmark-style server-side
|
|
679
|
-
// validation case.
|
|
680
|
-
const afterSubmitText = (await this.browser.extractText()).slice(0, 4000);
|
|
681
|
-
if (this.looksLikeValidationFailure(afterSubmitText)) {
|
|
682
|
-
steps.push("Post-submit text suggests validation errors — re-planning...");
|
|
683
|
-
const state2 = await this.browser.getState();
|
|
684
|
-
const plan2 = await this.planSignupForm({
|
|
685
|
-
service: task.service,
|
|
686
|
-
url: state2.url,
|
|
687
|
-
html: state2.html,
|
|
688
|
-
screenshot: state2.screenshot,
|
|
689
|
-
hint: `Previous submit produced validation errors. Visible page text snippet: ${afterSubmitText.slice(0, 800)}`,
|
|
690
|
-
});
|
|
691
|
-
await this.executePlan(plan2, fillValues, steps);
|
|
692
|
-
// Re-plan path: same captcha guard as initial submit. If the
|
|
693
|
-
// first submit triggered captcha rendering, the second pass
|
|
694
|
-
// sees it inline.
|
|
695
|
-
const replanGate = await this.runCaptchaGate("Re-plan", steps);
|
|
696
|
-
if (replanGate.blocked) {
|
|
832
|
+
const outcome = await this.planExecuteWithRetry(task, fillValues, steps);
|
|
833
|
+
switch (outcome.kind) {
|
|
834
|
+
case "captcha_blocked":
|
|
697
835
|
return {
|
|
698
836
|
success: false,
|
|
699
|
-
error: `captcha_blocked:
|
|
837
|
+
error: `captcha_blocked: ${outcome.captchaKind} challenge did not resolve. The site flagged this session.`,
|
|
700
838
|
steps,
|
|
701
839
|
...this.resultTail(),
|
|
702
840
|
};
|
|
703
|
-
|
|
704
|
-
try {
|
|
705
|
-
await this.browser.clickSubmit(plan2.submit_selector);
|
|
706
|
-
}
|
|
707
|
-
catch (err) {
|
|
708
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
709
|
-
steps.push(`⚠ re-plan submit click failed: ${reason}`);
|
|
841
|
+
case "submit_failed":
|
|
710
842
|
return {
|
|
711
843
|
success: false,
|
|
712
|
-
error: `submit_failed:
|
|
844
|
+
error: `submit_failed: could not click the signup button — ${outcome.reason}`,
|
|
713
845
|
steps,
|
|
714
846
|
...this.resultTail(),
|
|
715
847
|
};
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
848
|
+
case "planning_failed":
|
|
849
|
+
return {
|
|
850
|
+
success: false,
|
|
851
|
+
error: `planning_failed: ${outcome.reason}`,
|
|
852
|
+
steps,
|
|
853
|
+
...this.resultTail(),
|
|
854
|
+
};
|
|
855
|
+
case "oauth_required":
|
|
856
|
+
return {
|
|
857
|
+
success: false,
|
|
858
|
+
error: `oauth_required: ${task.service} offers only OAuth/SSO signup — there is no email/password form to automate.`,
|
|
859
|
+
steps,
|
|
860
|
+
...this.resultTail(),
|
|
861
|
+
};
|
|
862
|
+
case "submitted":
|
|
863
|
+
break;
|
|
719
864
|
}
|
|
865
|
+
await saveDebugSnapshot(this.browser, "after-submit");
|
|
720
866
|
// Step 6: Extract creds from page.
|
|
721
867
|
steps.push("Extracting credentials from page...");
|
|
722
868
|
let credentials = await this.extractCredentials();
|
|
@@ -794,62 +940,62 @@ export class SignupAgent {
|
|
|
794
940
|
}
|
|
795
941
|
// ------------ Claude planner ------------
|
|
796
942
|
async planSignupForm(input) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
943
|
+
const systemPrompt = `You plan how to fill a web signup form.
|
|
944
|
+
|
|
945
|
+
You are given a screenshot of the page and an INVENTORY of its
|
|
946
|
+
interactive elements — each line carries a precise \`selector=\` the
|
|
947
|
+
bot has already verified resolves. Your job: pick which inventory
|
|
948
|
+
elements to fill / check / click.
|
|
949
|
+
|
|
801
950
|
Output rules:
|
|
802
951
|
- Reply with ONE JSON object only. No prose, no markdown.
|
|
803
952
|
- Schema:
|
|
804
953
|
{
|
|
805
954
|
"actions": [
|
|
806
|
-
{"kind":"fill","selector":"
|
|
807
|
-
{"kind":"check","selector":"
|
|
808
|
-
{"kind":"click","selector":"
|
|
955
|
+
{"kind":"fill","selector":"<a selector= copied verbatim from the inventory>","value_kind":"email|password|name|username|company|literal","literal":"only when value_kind=literal","reason":"why"},
|
|
956
|
+
{"kind":"check","selector":"<from inventory>","reason":"TOS checkbox etc."},
|
|
957
|
+
{"kind":"click","selector":"<from inventory>","reason":"e.g. reveal the email form"}
|
|
809
958
|
],
|
|
810
|
-
"submit_selector": "
|
|
959
|
+
"submit_selector": "<a selector= from the inventory — the primary signup button>",
|
|
811
960
|
"confidence": "high|medium|low",
|
|
812
|
-
"notes": "optional
|
|
961
|
+
"notes": "optional"
|
|
813
962
|
}
|
|
814
|
-
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
-
|
|
819
|
-
-
|
|
963
|
+
- CRITICAL: every "selector" you emit MUST be copied verbatim from a
|
|
964
|
+
\`selector=\` field in the inventory below. Never invent, guess, or
|
|
965
|
+
modify a selector. A selector not in the inventory is rejected and
|
|
966
|
+
you will be asked to re-plan.
|
|
967
|
+
- Include the TOS/agree checkbox if the form has one.
|
|
968
|
+
- Skip elements marked [cookie-consent — avoid], and skip optional
|
|
969
|
+
marketing-opt-in checkboxes.
|
|
970
|
+
- Do NOT add a separate password-confirmation fill unless the
|
|
971
|
+
inventory shows a second password field.
|
|
972
|
+
- Two-stage pages: if the inventory has only buttons (e.g. "Sign up
|
|
973
|
+
with email" / "Continue with Google") and no input fields, emit a
|
|
974
|
+
single click action on the EMAIL-signup button, and set
|
|
975
|
+
submit_selector to that same button.
|
|
976
|
+
- For "name" use a realistic full name; for "username" a plausible
|
|
977
|
+
7-15 char handle.`;
|
|
978
|
+
const hintLine = input.hint !== undefined ? `\nHint: ${input.hint}` : "";
|
|
820
979
|
const userBlocks = [
|
|
821
980
|
{ kind: "image", media_type: "image/png", data_base64: input.screenshot },
|
|
822
981
|
{
|
|
823
982
|
kind: "text",
|
|
824
983
|
text: `Service: ${input.service}
|
|
825
|
-
URL: ${input.url}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
${
|
|
984
|
+
URL: ${input.url}${hintLine}
|
|
985
|
+
|
|
986
|
+
Interactive element inventory:
|
|
987
|
+
${formatInventory(input.inventory)}`,
|
|
829
988
|
},
|
|
830
989
|
];
|
|
990
|
+
// F3 T4: the planner may only pick selectors the bot supplied.
|
|
991
|
+
const allowed = new Set(input.inventory.map((e) => e.selector));
|
|
831
992
|
return this.callLLM({
|
|
832
993
|
system: systemPrompt,
|
|
833
994
|
userBlocks,
|
|
834
995
|
maxTokens: 1500,
|
|
835
|
-
parse: parseSignupPlan,
|
|
996
|
+
parse: (raw) => parseSignupPlan(raw, allowed),
|
|
836
997
|
});
|
|
837
998
|
}
|
|
838
|
-
// Extract just <form> elements from the HTML — drops marketing + scripts.
|
|
839
|
-
extractFormHtml(html) {
|
|
840
|
-
const forms = [];
|
|
841
|
-
const re = /<form\b[\s\S]*?<\/form>/gi;
|
|
842
|
-
let m;
|
|
843
|
-
while ((m = re.exec(html)) !== null)
|
|
844
|
-
forms.push(m[0]);
|
|
845
|
-
const joined = forms.join("\n");
|
|
846
|
-
if (joined.length === 0) {
|
|
847
|
-
// No <form> — fall back to body but cap aggressively
|
|
848
|
-
return html.slice(0, 20000);
|
|
849
|
-
}
|
|
850
|
-
// Cap at 20k chars (~5k tokens) to leave room for the screenshot and reply
|
|
851
|
-
return joined.slice(0, 20000);
|
|
852
|
-
}
|
|
853
999
|
looksLikeValidationFailure(text) {
|
|
854
1000
|
const t = text.toLowerCase();
|
|
855
1001
|
return (t.includes("must be between") ||
|