Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 55 additions & 13 deletions src/chrome/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -3007,6 +3007,55 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
if (!pageUrl) {
try { pageUrl = await this._currentUrl(tabId); } catch {}
}
let detection = null;
let detectionFailed = false;
let failedDiagnostics = null;
let detectionAttempted = false;
const inspectCaptchaFrames = async () => {
if (detectionAttempted) return;
detectionAttempted = true;
try {
detection = await detectCaptcha(tabId);
} catch (error) {
detectionFailed = true;
failedDiagnostics = error?.captchaDiagnostics || null;
}
};
let languageNeutralFrameTrigger = false;
const hasDialogSurface = toolResult.pageGate?.surface === 'dialog'
|| /^\s*(?:dialog|alertdialog)(?=\s|$)/im.test(toolResult.pageContent);
if (!challenge && hasDialogSurface) {
await inspectCaptchaFrames();
const selectedActiveFrame = detection?.selected?.activeChallengeFrame === true
&& detection?.selected?.visible === true
&& detection?.selected?.frameVisible !== false;
const diagnosticActiveFrame = (detection?.diagnostics?.frames || []).find(frame =>
frame?.activeChallengeFrame === true && frame?.visible === true
);
if (selectedActiveFrame || diagnosticActiveFrame) {
const vendor = diagnosticActiveFrame?.vendor
|| (String(detection?.selected?.type || '').startsWith('recaptcha')
? 'recaptcha'
: detection?.selected?.type || 'captcha');
const vendorLabel = vendor === 'recaptcha'
? 'reCAPTCHA'
: vendor === 'hcaptcha'
? 'hCaptcha'
: vendor === 'arkose'
? 'Arkose'
: 'CAPTCHA';
challenge = detectChallengeDialog(`dialog ${JSON.stringify(`Visible ${vendorLabel} CAPTCHA challenge`)}`);
languageNeutralFrameTrigger = !!challenge;
if (selectedActiveFrame) {
observedChallengeFrameId = Number.isInteger(detection.selected.frameId)
? detection.selected.frameId
: observedChallengeFrameId;
observedChallengeFrameUrl = String(
detection.selected.frameUrl || observedChallengeFrameUrl
);
}
}
}
const requestedPage = toolArgs?.page;
const requestedMaxDepth = toolArgs?.maxDepth;
const parsedMaxDepth = Number(requestedMaxDepth);
Expand Down Expand Up @@ -3157,17 +3206,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
return { gate: existing.publicGate, loopCheck };
}

let detection = null;
let detectionFailed = false;
let failedDiagnostics = null;
if (this.captchaSolverEnabled) {
try {
detection = await detectCaptcha(tabId);
} catch (error) {
detectionFailed = true;
failedDiagnostics = error?.captchaDiagnostics || null;
}
}
await inspectCaptchaFrames();
const diagnostics = detection?.diagnostics || failedDiagnostics || {
vendors: [],
candidateTypes: [],
Expand All @@ -3181,8 +3220,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
&& frame?.visible === true
))
.map(frame => frame.vendor))];
const selectedCorrelated = detection?.selected?.dialogAssociated === true
&& detection?.selected?.frameVisible !== false;
const selectedCorrelated = (
detection?.selected?.dialogAssociated === true
|| detection?.selected?.activeChallengeFrame === true
) && detection?.selected?.frameVisible !== false;
const supported = this.captchaSolverEnabled
&& !detectionFailed
&& !detection?.error
Expand All @@ -3199,6 +3240,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
...(!this.captchaSolverEnabled ? { solverDisabled: true } : {}),
...(detection?.error ? { selectionFailed: true } : {}),
...(detection?.selected && !selectedCorrelated ? { candidateNotCorrelated: true } : {}),
...(languageNeutralFrameTrigger ? { languageNeutralFrameTrigger: true } : {}),
};
this._captchaGateStates.set(tabId, {
key,
Expand Down
66 changes: 61 additions & 5 deletions src/chrome/src/agent/captcha-frame-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ function candidateSummary(candidate) {
visible: candidate?.visible === true,
normalCheckbox: candidate?.normalCheckbox === true,
challengeFrame: candidate?.challengeFrame === true,
activeChallengeFrame: candidate?.activeChallengeFrame === true,
dialogAssociated: candidate?.dialogAssociated === true,
frameVisible: candidate?.frameVisible !== false,
isInvisible: candidate?.isInvisible === true,
Expand Down Expand Up @@ -326,7 +327,7 @@ function candidateScore(candidate) {
// Priority is tiered so no combination of secondary signals can make a
// generic visible/background integration outrank an active challenge
// frame or visible checkbox.
const activeChallengeFrame = candidate?.challengeFrame
const activeChallengeFrame = candidate?.activeChallengeFrame
&& candidate?.visible === true
&& candidate?.frameVisible !== false;
const primary = (candidate?.normalCheckbox && candidate?.visible) || activeChallengeFrame;
Expand Down Expand Up @@ -406,6 +407,8 @@ export function selectCaptchaCandidate(candidates, constraints = {}) {
visible: previous.visible === true || candidate.visible === true,
normalCheckbox: previous.normalCheckbox === true || candidate.normalCheckbox === true,
challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true,
activeChallengeFrame: previous.activeChallengeFrame === true
|| candidate.activeChallengeFrame === true,
dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true,
responseField: previous.responseField === true || candidate.responseField === true,
responseTokenPresent: previous.responseTokenPresent === true
Expand Down Expand Up @@ -580,7 +583,7 @@ function selectedReason(candidate, constraints) {
if (constraints.frameUrl) return 'exact frameUrl match';
if (constraints.websiteKey) return 'exact websiteKey match';
if (candidate.normalCheckbox && candidate.visible) return 'visible checkbox challenge';
if (candidate.visible && candidate.challengeFrame && candidate.frameVisible !== false) return 'visible challenge frame';
if (candidate.visible && candidate.activeChallengeFrame && candidate.frameVisible !== false) return 'visible challenge frame';
if (candidate.visible) return 'visible CAPTCHA widget';
if (candidate.challengeFrame && candidate.frameVisible !== false) return 'challenge frame candidate';
return 'only detected CAPTCHA candidate';
Expand Down Expand Up @@ -627,6 +630,29 @@ export function detectCaptchaCandidatesInPage(scope = null) {
return null;
}
};
// Page-serialized counterpart of captchaActiveChallengeFrameVendor in
// captcha-gate.js. Only challenge routes, never checkbox routes, may arm it.
const activeChallengeFrameVendor = (urlStr) => {
try {
const parsed = new URL(String(urlStr || ''), frameUrl || 'https://dummy.host');
const host = parsed.hostname.toLowerCase();
const path = parsed.pathname.toLowerCase();
if (
(/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host))
&& /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path)
) return 'recaptcha';
if (/(^|\.)hcaptcha\.com$/.test(host)) {
const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame');
if (frame === 'challenge') return 'hcaptcha';
}
if (
/(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host)
&& /\/fc\/gc(?:\/|$)/.test(path)
) return 'arkose';
} catch (_) {}
return '';
};
const documentChallengeVendor = activeChallengeFrameVendor(frameUrl);
const visibleElement = (element) => {
if (!element) return false;
try {
Expand Down Expand Up @@ -714,7 +740,9 @@ export function detectCaptchaCandidatesInPage(scope = null) {
candidates.push({
...serializableCandidate,
frameUrl,
challengeFrame,
challengeFrame: candidate.challengeFrame === true || challengeFrame,
activeChallengeFrame: candidate.activeChallengeFrame === true
|| !!documentChallengeVendor,
responseField,
responseTokenPresent: candidate.responseTokenPresent === true
|| alsoResponseTokenPresent === true,
Expand Down Expand Up @@ -873,16 +901,21 @@ export function detectCaptchaCandidatesInPage(scope = null) {
const recaptchaFrames = iframeUrls.filter(({ url }) =>
/recaptcha\/(api2|enterprise)\/anchor/i.test(url)
);
const recaptchaChallengeFrames = iframeUrls.filter(({ url }) =>
activeChallengeFrameVendor(url) === 'recaptcha'
);

for (const { element, url } of iframeUrls) {
if (/hcaptcha\.com/i.test(url)) {
const websiteKey = urlParam(url, 'sitekey');
if (websiteKey) {
const activeChallengeFrame = activeChallengeFrameVendor(url) === 'hcaptcha';
add({
type: 'hcaptcha',
websiteKey,
visible: visibleElement(element),
normalCheckbox: visibleElement(element),
normalCheckbox: visibleElement(element) && !activeChallengeFrame,
activeChallengeFrame,
...responseFieldIdentity(
element,
'h-captcha-response',
Expand Down Expand Up @@ -919,10 +952,31 @@ export function detectCaptchaCandidatesInPage(scope = null) {
}
continue;
}
if (!/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue;
const recaptchaChallengeFrame = activeChallengeFrameVendor(url) === 'recaptcha';
if (!recaptchaChallengeFrame && !/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue;
const websiteKey = urlParam(url, 'k');
if (!websiteKey) continue;
const isEnterprise = /recaptcha\/enterprise/i.test(url);
if (recaptchaChallengeFrame) {
const challengeIndex = recaptchaChallengeFrames.findIndex(frame => frame.element === element);
add({
type: isEnterprise ? 'recaptcha_v2_enterprise' : 'recaptcha_v2',
websiteKey,
isInvisible: false,
isEnterprise,
visible: visibleElement(element),
normalCheckbox: false,
activeChallengeFrame: true,
...responseFieldIdentity(
element,
'g-recaptcha-response',
challengeIndex,
recaptchaChallengeFrames.length,
),
detectedVia: 'url',
}, element);
continue;
}
const isInvisible = urlParam(url, 'size') === 'invisible';
const matchingV3Script = scriptUrls.find(scriptUrl => urlParam(scriptUrl, 'render') === websiteKey) || null;
const isV3 = !!matchingV3Script;
Expand Down Expand Up @@ -994,13 +1048,15 @@ export function detectCaptchaCandidatesInPage(scope = null) {
try { url = String(element.src || ''); } catch (_) {}
try { loadedUrl = String(element.contentWindow?.location?.href || ''); } catch (_) {}
try { name = String(element.name || element.getAttribute?.('name') || ''); } catch (_) {}
const activeChallengeFrame = !!activeChallengeFrameVendor(loadedUrl || url);
return {
index,
url,
loadedUrl,
name,
visible: visibleElement(element),
dialogAssociated: elementInChallengeDialog(element),
activeChallengeFrame,
};
});
let frameName = '';
Expand Down
53 changes: 51 additions & 2 deletions src/chrome/src/agent/captcha-gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,42 @@ export function detectChallengeDialogInPage(options = null) {
return false;
}
};
// This detector is serialized into the page, so it cannot call the exported
// classifier below. Keep the vendor-specific routes aligned with it.
const activeFrameVendor = (value) => {
try {
const parsed = new URL(String(value || ''));
const host = parsed.hostname.toLowerCase();
const path = parsed.pathname.toLowerCase();
if (
(/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host))
&& /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path)
) return 'recaptcha';
if (/(^|\.)hcaptcha\.com$/.test(host)) {
const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame');
if (frame === 'challenge') return 'hcaptcha';
}
if (
/(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host)
&& /\/fc\/gc(?:\/|$)/.test(path)
) return 'arkose';
} catch {}
return '';
};
const childFrames = Array.from(document.querySelectorAll('iframe')).map((element, index) => {
let loadedUrl = '';
try {
loadedUrl = String(element.contentWindow?.location?.href || '');
} catch {}
const url = String(element.getAttribute?.('src') || element.src || '');
const activeChallengeVendor = activeFrameVendor(loadedUrl || url);
return {
index,
url: String(element.getAttribute?.('src') || element.src || ''),
url,
loadedUrl,
name: String(element.getAttribute?.('name') || element.name || ''),
visible: visible(element),
...(activeChallengeVendor ? { activeChallengeVendor } : {}),
};
});
// A challenge dialog that exists in the DOM but is hidden or off-viewport
Expand Down Expand Up @@ -250,6 +275,20 @@ export function detectChallengeDialogInPage(options = null) {
if (label) return finish({ label });
}
}
const activeChallengeFrame = childFrames.find(frame =>
frame.visible === true && frame.activeChallengeVendor
);
if (activeChallengeFrame) {
const vendorLabel = activeChallengeFrame.activeChallengeVendor === 'recaptcha'
? 'reCAPTCHA'
: activeChallengeFrame.activeChallengeVendor === 'hcaptcha'
? 'hCaptcha'
: 'Arkose';
return finish({
label: `Visible ${vendorLabel} challenge frame`,
languageNeutralFrame: true,
});
}
return finish(null);
}

Expand Down Expand Up @@ -302,7 +341,14 @@ export function buildCaptchaDiagnostics({
} = {}) {
const rows = [];
const seen = new Set();
const addFrame = ({ frameId = null, parentFrameId = null, frameUrl = '', source, visible = null }) => {
const addFrame = ({
frameId = null,
parentFrameId = null,
frameUrl = '',
source,
visible = null,
activeChallengeFrame = false,
}) => {
const sanitizedUrl = sanitizeCaptchaFrameUrl(frameUrl);
if (!sanitizedUrl) return;
const vendor = captchaVendorFromUrl(frameUrl);
Expand All @@ -315,6 +361,7 @@ export function buildCaptchaDiagnostics({
frameUrl: sanitizedUrl,
vendor,
source,
...(activeChallengeFrame ? { activeChallengeFrame: true } : {}),
...(typeof visible === 'boolean' ? { visible } : {}),
});
};
Expand All @@ -338,6 +385,7 @@ export function buildCaptchaDiagnostics({
frameUrl: child?.loadedUrl || child?.url,
source: 'embedded',
visible: child?.visible,
activeChallengeFrame: child?.activeChallengeFrame === true,
});
}
}
Expand All @@ -347,6 +395,7 @@ export function buildCaptchaDiagnostics({
frameUrl: candidate?.frameUrl,
source: 'candidate',
visible: candidate?.visible,
activeChallengeFrame: candidate?.activeChallengeFrame === true,
});
}

Expand Down
Loading
Loading