Skip to content

Commit d0f1610

Browse files
esokulluclaude
andcommitted
fix(captcha): fail closed when the DOM rescan cannot disprove a tree-observed challenge
The visibility recheck added for all-filter accessibility-tree reads cleared the challenge whenever detectChallengeDialogInPage found no visible dialog. That scan is not authoritative: it was blind to open shadow roots (which the tree pierces on LinkedIn/Bilibili), and an injection failure was indistinguishable from a confirmed-hidden dialog — so a real challenge could be discarded and the CAPTCHA gate bypassed by the next mutation. - detectChallengeDialogInPage now scans open shadow roots (bounded depth), walks visibility through shadow hosts, resolves aria-labelledby against the element's root node, and reports a matching-but-hidden dialog separately as hiddenChallenge. - _detectChallengeDialogBeforeMutation exposes challengeHidden and reports inspectionComplete:false when no frame was actually inspected. - _observeCaptchaChallenge clears a tree-observed challenge only when the rescan completed AND positively confirmed the dialog hidden; "not found" and inconclusive scans keep the gate armed. Fixes the two fail-open test regressions on this branch (unsupported-Arkose routing and manual-completion batch) and addresses the P1 review finding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b11fb12 commit d0f1610

5 files changed

Lines changed: 261 additions & 27 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2836,9 +2836,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
28362836
return includeStatus
28372837
? {
28382838
challenge,
2839+
challengeHidden: entries.some(entry =>
2840+
entry.payload && typeof entry.payload === 'object' && entry.payload.hiddenChallenge?.label
2841+
),
2842+
// With no expected frame, "complete" still requires at least one
2843+
// frame to have actually been inspected.
28392844
inspectionComplete: expectedFrameId === null
2840-
|| inspectedFrameIds.has(expectedFrameId)
2841-
|| (navigationInspectionComplete && !expectedFrameStillExists),
2845+
? inspectedFrameIds.size > 0
2846+
: (inspectedFrameIds.has(expectedFrameId)
2847+
|| (navigationInspectionComplete && !expectedFrameStillExists)),
28422848
}
28432849
: challenge;
28442850
};
@@ -2851,7 +2857,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
28512857
return inspected(results);
28522858
} catch {
28532859
return includeStatus
2854-
? { challenge: null, inspectionComplete: false }
2860+
? { challenge: null, challengeHidden: false, inspectionComplete: false }
28552861
: null;
28562862
}
28572863
}
@@ -2917,21 +2923,28 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
29172923
&& treeFilter !== 'visible'
29182924
&& toolResult.captchaChallengeVisibilityConfirmed !== true
29192925
) {
2920-
const visibleChallenge = await this._detectChallengeDialogBeforeMutation(tabId, {
2926+
const recheck = await this._detectChallengeDialogBeforeMutation(tabId, {
2927+
includeStatus: true,
2928+
expectedFrameId: observedChallengeFrameId,
29212929
allowGenericFailure: !!activeGate,
29222930
});
2923-
if (visibleChallenge?.label) {
2931+
if (recheck.challenge?.label) {
29242932
challenge = detectChallengeDialog(
2925-
`dialog ${JSON.stringify(String(visibleChallenge.label).slice(0, 200))}`,
2933+
`dialog ${JSON.stringify(String(recheck.challenge.label).slice(0, 200))}`,
29262934
{ allowGenericFailure: !!activeGate },
29272935
);
2928-
observedChallengeFrameId = Number.isInteger(visibleChallenge.frameId)
2929-
? visibleChallenge.frameId
2936+
observedChallengeFrameId = Number.isInteger(recheck.challenge.frameId)
2937+
? recheck.challenge.frameId
29302938
: null;
2931-
observedChallengeFrameUrl = String(visibleChallenge.frameUrl || '');
2932-
} else {
2939+
observedChallengeFrameUrl = String(recheck.challenge.frameUrl || '');
2940+
} else if (recheck.inspectionComplete && recheck.challengeHidden) {
29332941
challenge = null;
29342942
}
2943+
// Only a completed scan that positively found the dialog hidden clears
2944+
// the tree's observation. "Not found" is not disproof — the DOM scan
2945+
// cannot see closed shadow roots or unreachable frames the tree can —
2946+
// and an inconclusive scan proves nothing; keep the challenge rather
2947+
// than failing the gate open.
29352948
}
29362949
let pageUrl = String(toolResult.currentUrl || toolResult.pageUrl || '');
29372950
if (!pageUrl) {

src/chrome/src/agent/captcha-gate.js

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ export function detectChallengeDialogInPage(options = null) {
112112
const style = getComputedStyle(current);
113113
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
114114
if (current.hidden || current.getAttribute?.('aria-hidden') === 'true') return false;
115-
current = current.parentElement || null;
115+
current = current.parentElement
116+
|| (typeof current.getRootNode === 'function' ? current.getRootNode()?.host : null)
117+
|| null;
116118
}
117119
const rect = element.getBoundingClientRect();
118120
if (rect.width <= 0 || rect.height <= 0) return false;
@@ -143,24 +145,80 @@ export function detectChallengeDialogInPage(options = null) {
143145
visible: visible(element),
144146
};
145147
});
148+
// A challenge dialog that exists in the DOM but is hidden or off-viewport
149+
// is positive evidence the challenge is inactive. Report it separately so
150+
// callers can distinguish "confirmed hidden" from "not found" — a dialog
151+
// this scan cannot see at all (closed shadow root, unreachable frame) must
152+
// not be treated as disproved.
153+
let hiddenChallenge = null;
146154
const finish = challenge => includeFrameContext
147155
? {
148156
challenge,
157+
...(hiddenChallenge ? { hiddenChallenge } : {}),
149158
frameContext: {
150159
frameUrl,
151160
frameName,
152161
childFrames,
153162
},
154163
}
155164
: challenge;
156-
for (const element of document.querySelectorAll('dialog, [role="dialog"], [role="alertdialog"]')) {
157-
if (!visible(element)) continue;
165+
// The accessibility tree pierces open shadow roots on some sites, where
166+
// frameworks (e.g. LinkedIn's interop shell) render whole dialogs that
167+
// document.querySelectorAll cannot see. Scan open shadow roots too so this
168+
// preflight can confirm the same dialogs the tree reports.
169+
const dialogSelector = 'dialog, [role="dialog"], [role="alertdialog"]';
170+
const dialogCandidates = [];
171+
const collectDialogs = (root, depth) => {
172+
try {
173+
for (const element of root.querySelectorAll(dialogSelector)) {
174+
if (dialogCandidates.length >= 200) break;
175+
dialogCandidates.push(element);
176+
}
177+
} catch {}
178+
if (depth >= 4) return;
179+
let descendants;
180+
try { descendants = root.querySelectorAll('*'); } catch { return; }
181+
for (const host of descendants) {
182+
if (host.shadowRoot) collectDialogs(host.shadowRoot, depth + 1);
183+
}
184+
};
185+
collectDialogs(document, 0);
186+
const recordHiddenChallenge = (element) => {
187+
if (hiddenChallenge) return;
188+
try {
189+
const values = [
190+
element.getAttribute?.('aria-label'),
191+
element.getAttribute?.('title'),
192+
element.innerText,
193+
element.textContent,
194+
...Array.from(element.querySelectorAll?.('h1, h2, h3, [role="heading"]') || [])
195+
.map(heading => heading.textContent || ''),
196+
];
197+
for (const value of values) {
198+
const text = String(value || '');
199+
if (!matchesChallenge(text)) continue;
200+
const line = text.split(/\r?\n/).find(entry => matchesChallenge(entry)) || text;
201+
const label = line.replace(/\s+/g, ' ').trim().slice(0, 200);
202+
if (label) {
203+
hiddenChallenge = { label };
204+
return;
205+
}
206+
}
207+
} catch {}
208+
};
209+
for (const element of dialogCandidates) {
210+
if (!visible(element)) {
211+
recordHiddenChallenge(element);
212+
continue;
213+
}
158214
let labelledBy = '';
159215
try {
216+
const idRoot = (typeof element.getRootNode === 'function' && element.getRootNode()) || document;
160217
labelledBy = String(element.getAttribute('aria-labelledby') || '')
161218
.split(/\s+/)
162219
.filter(Boolean)
163-
.map(id => document.getElementById(id)?.textContent || '')
220+
.map(id => (typeof idRoot.getElementById === 'function' ? idRoot : document)
221+
.getElementById(id)?.textContent || '')
164222
.join(' ');
165223
} catch {}
166224
let renderedHeadings = '';

src/firefox/src/agent/agent.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2517,9 +2517,13 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
25172517
&& navigationFrames.some(frame => frame?.frameId === expectedFrameId);
25182518
return {
25192519
challenge,
2520+
challengeHidden: successfulEntries.some(entry => entry.payload.hiddenChallenge?.label),
2521+
// With no expected frame, "complete" still requires at least one frame
2522+
// to have actually been inspected.
25202523
inspectionComplete: expectedFrameId === null
2521-
|| inspectedFrameIds.has(expectedFrameId)
2522-
|| (navigationInspectionComplete && !expectedFrameStillExists),
2524+
? inspectedFrameIds.size > 0
2525+
: (inspectedFrameIds.has(expectedFrameId)
2526+
|| (navigationInspectionComplete && !expectedFrameStillExists)),
25232527
};
25242528
}
25252529

@@ -2583,21 +2587,28 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
25832587
&& treeFilter !== 'visible'
25842588
&& toolResult.captchaChallengeVisibilityConfirmed !== true
25852589
) {
2586-
const visibleChallenge = await this._detectChallengeDialogBeforeMutation(tabId, {
2590+
const recheck = await this._detectChallengeDialogBeforeMutation(tabId, {
2591+
includeStatus: true,
2592+
expectedFrameId: observedChallengeFrameId,
25872593
allowGenericFailure: !!activeGate,
25882594
});
2589-
if (visibleChallenge?.label) {
2595+
if (recheck.challenge?.label) {
25902596
challenge = detectChallengeDialog(
2591-
`dialog ${JSON.stringify(String(visibleChallenge.label).slice(0, 200))}`,
2597+
`dialog ${JSON.stringify(String(recheck.challenge.label).slice(0, 200))}`,
25922598
{ allowGenericFailure: !!activeGate },
25932599
);
2594-
observedChallengeFrameId = Number.isInteger(visibleChallenge.frameId)
2595-
? visibleChallenge.frameId
2600+
observedChallengeFrameId = Number.isInteger(recheck.challenge.frameId)
2601+
? recheck.challenge.frameId
25962602
: null;
2597-
observedChallengeFrameUrl = String(visibleChallenge.frameUrl || '');
2598-
} else {
2603+
observedChallengeFrameUrl = String(recheck.challenge.frameUrl || '');
2604+
} else if (recheck.inspectionComplete && recheck.challengeHidden) {
25992605
challenge = null;
26002606
}
2607+
// Only a completed scan that positively found the dialog hidden clears
2608+
// the tree's observation. "Not found" is not disproof — the DOM scan
2609+
// cannot see closed shadow roots or unreachable frames the tree can —
2610+
// and an inconclusive scan proves nothing; keep the challenge rather
2611+
// than failing the gate open.
26012612
}
26022613
let pageUrl = String(toolResult.currentUrl || toolResult.pageUrl || '');
26032614
if (!pageUrl) {

src/firefox/src/agent/captcha-gate.js

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ export function detectChallengeDialogInPage(options = null) {
112112
const style = getComputedStyle(current);
113113
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
114114
if (current.hidden || current.getAttribute?.('aria-hidden') === 'true') return false;
115-
current = current.parentElement || null;
115+
current = current.parentElement
116+
|| (typeof current.getRootNode === 'function' ? current.getRootNode()?.host : null)
117+
|| null;
116118
}
117119
const rect = element.getBoundingClientRect();
118120
if (rect.width <= 0 || rect.height <= 0) return false;
@@ -143,24 +145,80 @@ export function detectChallengeDialogInPage(options = null) {
143145
visible: visible(element),
144146
};
145147
});
148+
// A challenge dialog that exists in the DOM but is hidden or off-viewport
149+
// is positive evidence the challenge is inactive. Report it separately so
150+
// callers can distinguish "confirmed hidden" from "not found" — a dialog
151+
// this scan cannot see at all (closed shadow root, unreachable frame) must
152+
// not be treated as disproved.
153+
let hiddenChallenge = null;
146154
const finish = challenge => includeFrameContext
147155
? {
148156
challenge,
157+
...(hiddenChallenge ? { hiddenChallenge } : {}),
149158
frameContext: {
150159
frameUrl,
151160
frameName,
152161
childFrames,
153162
},
154163
}
155164
: challenge;
156-
for (const element of document.querySelectorAll('dialog, [role="dialog"], [role="alertdialog"]')) {
157-
if (!visible(element)) continue;
165+
// The accessibility tree pierces open shadow roots on some sites, where
166+
// frameworks (e.g. LinkedIn's interop shell) render whole dialogs that
167+
// document.querySelectorAll cannot see. Scan open shadow roots too so this
168+
// preflight can confirm the same dialogs the tree reports.
169+
const dialogSelector = 'dialog, [role="dialog"], [role="alertdialog"]';
170+
const dialogCandidates = [];
171+
const collectDialogs = (root, depth) => {
172+
try {
173+
for (const element of root.querySelectorAll(dialogSelector)) {
174+
if (dialogCandidates.length >= 200) break;
175+
dialogCandidates.push(element);
176+
}
177+
} catch {}
178+
if (depth >= 4) return;
179+
let descendants;
180+
try { descendants = root.querySelectorAll('*'); } catch { return; }
181+
for (const host of descendants) {
182+
if (host.shadowRoot) collectDialogs(host.shadowRoot, depth + 1);
183+
}
184+
};
185+
collectDialogs(document, 0);
186+
const recordHiddenChallenge = (element) => {
187+
if (hiddenChallenge) return;
188+
try {
189+
const values = [
190+
element.getAttribute?.('aria-label'),
191+
element.getAttribute?.('title'),
192+
element.innerText,
193+
element.textContent,
194+
...Array.from(element.querySelectorAll?.('h1, h2, h3, [role="heading"]') || [])
195+
.map(heading => heading.textContent || ''),
196+
];
197+
for (const value of values) {
198+
const text = String(value || '');
199+
if (!matchesChallenge(text)) continue;
200+
const line = text.split(/\r?\n/).find(entry => matchesChallenge(entry)) || text;
201+
const label = line.replace(/\s+/g, ' ').trim().slice(0, 200);
202+
if (label) {
203+
hiddenChallenge = { label };
204+
return;
205+
}
206+
}
207+
} catch {}
208+
};
209+
for (const element of dialogCandidates) {
210+
if (!visible(element)) {
211+
recordHiddenChallenge(element);
212+
continue;
213+
}
158214
let labelledBy = '';
159215
try {
216+
const idRoot = (typeof element.getRootNode === 'function' && element.getRootNode()) || document;
160217
labelledBy = String(element.getAttribute('aria-labelledby') || '')
161218
.split(/\s+/)
162219
.filter(Boolean)
163-
.map(id => document.getElementById(id)?.textContent || '')
220+
.map(id => (typeof idRoot.getElementById === 'function' ? idRoot : document)
221+
.getElementById(id)?.textContent || '')
164222
.join(' ');
165223
} catch {}
166224
let renderedHeadings = '';

0 commit comments

Comments
 (0)