Hand the token to the live captcha widget, not a dead one

First live run reset one account, then failed on the second: the confirm
button never enabled and the step reported it covered by a bare `div`.

reCAPTCHA adds a client to ___grecaptcha_cfg.clients per rendered widget and
removes none, so the second modal leaves two — and the callback walk fired the
first it found, the one belonging to the modal that already closed. The token
goes to a widget nobody is listening to, the live modal never hears it was
solved, and its button stays dark. Iteration 1 worked only because there was
one client to find. Now the newest wins: hook widgets are walked in reverse,
clients sorted by descending key, and the count of candidates is reported so
`2 widgets on the page, used the newest` says plainly when a page is stacking
stale ones.

Also from that run:

- coveredBy printed `div` and nothing else, which diagnoses nothing. It only
  ever reported tag and id, and modal backdrops and captcha overlays carry
  neither. It now names classes too.
- A covered target gets 8s to clear before the step fails. Most things over a
  button are on their way out — a backdrop mid-fade, an overlay closing. This
  does not weaken the check: still covered at the deadline still fails, and
  nothing is clicked while anything is on top of it.
- The reset list's scroll container is confirmed as a class, so the
  class-or-id hedge is trimmed. The run reported it as
  `dashboard_body MuiBox-root mui-0`.

Extension to 0.11.1 so the dashboard can confirm the reload took. Runner
VERSION deliberately unchanged — the step vocabulary is the same, and a bump
would raise the mismatch banner for a change that does not need it. The
runner does still need restarting to pick up the covered-retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 00:56:47 -05:00
co-authored by Claude Opus 5
parent 5538215650
commit 2d53639c82
5 changed files with 78 additions and 16 deletions
+40 -7
View File
@@ -216,6 +216,18 @@ function pageLocate(selector, index) {
const atPoint = document.elementFromPoint(cx, cy);
const covered = atPoint && atPoint !== el && !el.contains(atPoint);
// Enough to identify it. A bare tag name — the common case for a modal
// backdrop or a captcha overlay, neither of which carries an id — says
// nothing about which overlay is in the way. SVG's className is an object,
// not a string, hence the guard.
const describeNode = (node) => {
if (!node) return null;
const classes = typeof node.className === 'string'
? node.className.trim().split(/\s+/).filter(Boolean).slice(0, 4).map((c) => `.${c}`).join('')
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}${classes}`;
};
// Current contents, so the caller can confirm afterwards that what it typed
// actually landed — a field that silently ignored the keystrokes (masked,
// read-only, or never focused) is otherwise indistinguishable from success.
@@ -230,7 +242,7 @@ function pageLocate(selector, index) {
editable: (isField || el.isContentEditable) && !el.disabled && !el.readOnly,
inputType: isField ? (el.type || null) : null,
covered: !!covered,
coveredBy: covered ? `${atPoint.tagName.toLowerCase()}${atPoint.id ? '#' + atPoint.id : ''}` : null,
coveredBy: covered ? describeNode(atPoint) : null,
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
// Desktop coordinates of the element's centre, in CSS pixels.
screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy },
@@ -376,7 +388,10 @@ function pageCaptchaInfo(scopeSelector, wanted) {
}
};
for (const widget of (window.__autofirmerCaptcha?.widgets || [])) {
// Newest first. A page that renders a fresh widget per modal leaves the
// earlier ones in the registry — they are never unregistered — and the one
// that matters is the one just rendered, not the first ever seen.
for (const widget of [...(window.__autofirmerCaptcha?.widgets || [])].reverse()) {
const params = widget.params || {};
if (!want(widget.kind) || !params.sitekey) continue;
return {
@@ -482,9 +497,16 @@ function pageCaptchaSubmit(token, provider) {
}
};
for (const widget of (window.__autofirmerCaptcha?.widgets || [])) {
if (called) break;
// Newest first, for the same reason detection is: firing the callback of a
// widget belonging to a modal that has already closed hands the token to
// nobody, and the live widget never hears that it was solved — which looks
// from the outside like the site simply ignoring a correct token.
const hookWidgets = [...(window.__autofirmerCaptcha?.widgets || [])].reverse();
let candidates = 0;
for (const widget of hookWidgets) {
if (provider && provider !== 'auto' && widget.kind !== provider) continue;
candidates++;
if (called) continue;
const cb = (widget.params || {}).callback;
if (typeof cb === 'function') invoke(cb, `${widget.kind} render callback`);
else if (typeof cb === 'string') invoke(window[cb], cb);
@@ -499,9 +521,17 @@ function pageCaptchaSubmit(token, provider) {
// reCAPTCHA rendered declaratively keeps its callback in a private config
// object and nowhere else. The keys around it are minified; `callback` is not.
if (!called && window.___grecaptcha_cfg?.clients) {
if (window.___grecaptcha_cfg?.clients) {
// Clients accumulate: reCAPTCHA adds one per render and removes none, so
// after the second modal there are two and only the later one is live.
// Highest key first.
const clients = Object.entries(window.___grecaptcha_cfg.clients)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([, client]) => client);
candidates += clients.length;
const seen = new Set();
const queue = Object.values(window.___grecaptcha_cfg.clients);
const queue = called ? [] : clients;
let budget = 2000;
while (queue.length && !called && budget-- > 0) {
const node = queue.shift();
@@ -520,7 +550,10 @@ function pageCaptchaSubmit(token, provider) {
if (!filled && !called) {
return { error: 'nowhere to put the token — no response field and no callback on the page' };
}
return { filled, called, provider, url: location.href };
// `candidates` is the diagnostic that matters when a token is accepted and
// the page does nothing: more than one means the page has stale widgets, and
// the token went to whichever this picked.
return { filled, called, candidates, provider, url: location.href };
}
async function resolveLocateTab(cfg, request) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.11.0",
"version": "0.11.1",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
"permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [