@@ -52554,6 +52554,7 @@ function captchaEl(tag, attrs = {}, children = []) {
5255452554 src: attrs.src,
5255552555 innerText: attrs.innerText || '',
5255652556 textContent: attrs.textContent || attrs.innerText || '',
52557+ value: attrs.value || '',
5255752558 hidden: attrs.hidden === true,
5255852559 style: {},
5255952560 classList: { contains: (c) => String(attrs.class || '').split(/\s+/).includes(c) },
@@ -52685,6 +52686,13 @@ async function detectCaptchaOnFakePage(build, nodes) {
5268552686 });
5268652687}
5268752688
52689+ async function detectCaptchaDetailsOnFakePage(build, nodes) {
52690+ return withCaptchaFakePage(build, nodes, async () => {
52691+ const mod = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/agent/captcha-solver.js`)).href);
52692+ return mod.detectCaptcha(1);
52693+ });
52694+ }
52695+
5268852696test('challenge-dialog routing detects supported widgets and diagnoses unsupported Arkose frames', async () => {
5268952697 for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) {
5269052698 const supportedNodes = [
@@ -53136,12 +53144,17 @@ test('challenge dialog with no enabled supported solver stops the batch for manu
5313653144 }
5313753145});
5313853146
53139- test('CAPTCHA gate helper stays byte-identical and cloud traces retain gate diagnostics outside update rollover', () => {
53147+ test('CAPTCHA helpers stay byte-identical and cloud traces retain gate diagnostics outside update rollover', () => {
5314053148 assert.equal(
5314153149 fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/captcha-gate.js'), 'utf8'),
5314253150 fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/captcha-gate.js'), 'utf8'),
5314353151 'chrome and firefox CAPTCHA gate helpers must remain byte-identical',
5314453152 );
53153+ assert.equal(
53154+ fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/captcha-frame-runtime.js'), 'utf8'),
53155+ fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/captcha-frame-runtime.js'), 'utf8'),
53156+ 'chrome and firefox CAPTCHA frame runtimes must remain byte-identical',
53157+ );
5314553158 const cloudSource = fs.readFileSync(path.join(ROOT, 'src/chrome/src/cloud-runs.js'), 'utf8');
5314653159 assert.match(cloudSource, /if \(type === 'captcha_gate'\)[\s\S]*?run\.captchaDiagnostics = \{ \.\.\.scrubbedData, observedAt: run\.updatedAt \};/);
5314753160 assert.match(cloudSource, /\.\.\.\(run\.captchaDiagnostics \? \{ captchaDiagnostics: run\.captchaDiagnostics \} : \{\}\)/);
@@ -53401,6 +53414,110 @@ test('captcha detection binds the selected widget to its own response field', as
5340153414 }
5340253415});
5340353416
53417+ test('captcha detection reports exact response token state without exposing token values', async () => {
53418+ const token = 'RAW_CAPTCHA_TOKEN_MUST_NOT_ESCAPE_7fc2';
53419+ for (const build of ['chrome', 'firefox']) {
53420+ const solvedField = captchaEl('textarea', {
53421+ id: 'g-recaptcha-response-solved',
53422+ name: 'g-recaptcha-response',
53423+ value: token,
53424+ });
53425+ const pendingField = captchaEl('textarea', {
53426+ id: 'g-recaptcha-response-pending',
53427+ name: 'g-recaptcha-response',
53428+ });
53429+ const solvedHost = captchaEl('div', {
53430+ class: 'g-recaptcha',
53431+ 'data-sitekey': 'KEY_SOLVED_WIDGET',
53432+ hidden: true,
53433+ }, [solvedField]);
53434+ const pendingHost = captchaEl('div', {
53435+ class: 'g-recaptcha',
53436+ 'data-sitekey': 'KEY_PENDING_WIDGET',
53437+ }, [pendingField]);
53438+
53439+ const pendingDetection = await detectCaptchaDetailsOnFakePage(build, [
53440+ solvedHost,
53441+ pendingHost,
53442+ captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53443+ ]);
53444+ assert.equal(
53445+ pendingDetection.selected.responseTokenPresent,
53446+ false,
53447+ `${build}: a solved sibling widget contaminated the selected pending widget`,
53448+ );
53449+ assert.equal(
53450+ pendingDetection.candidates.find(candidate =>
53451+ candidate.websiteKey === 'KEY_SOLVED_WIDGET'
53452+ )?.responseTokenPresent,
53453+ true,
53454+ `${build}: non-empty exact response field was not reported as solved`,
53455+ );
53456+ assert.doesNotMatch(
53457+ JSON.stringify(pendingDetection),
53458+ new RegExp(token),
53459+ `${build}: raw response token escaped through detection`,
53460+ );
53461+
53462+ pendingField.value = token;
53463+ const solvedDetection = await detectCaptchaDetailsOnFakePage(build, [
53464+ solvedHost,
53465+ pendingHost,
53466+ captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53467+ ]);
53468+ assert.equal(
53469+ solvedDetection.selected.responseTokenPresent,
53470+ true,
53471+ `${build}: selected non-empty response field was not reported as solved`,
53472+ );
53473+ assert.doesNotMatch(
53474+ JSON.stringify(solvedDetection),
53475+ new RegExp(token),
53476+ `${build}: selected raw response token escaped through detection`,
53477+ );
53478+
53479+ const primaryField = captchaEl('textarea', {
53480+ id: 'h-captcha-response-primary',
53481+ name: 'h-captcha-response',
53482+ });
53483+ const compatibilityField = captchaEl('textarea', {
53484+ id: 'g-recaptcha-response-compatibility',
53485+ name: 'g-recaptcha-response',
53486+ value: token,
53487+ });
53488+ const hcaptcha = await detectCaptchaOnFakePage(build, [
53489+ captchaEl('div', {
53490+ class: 'h-captcha',
53491+ 'data-sitekey': 'HKEY_SOLVED_COMPATIBILITY',
53492+ }, [primaryField, compatibilityField]),
53493+ ]);
53494+ assert.equal(
53495+ hcaptcha.responseTokenPresent,
53496+ true,
53497+ `${build}: solved hCaptcha compatibility field was ignored`,
53498+ );
53499+ assert.doesNotMatch(
53500+ JSON.stringify(hcaptcha),
53501+ new RegExp(token),
53502+ `${build}: hCaptcha compatibility token escaped through detection`,
53503+ );
53504+
53505+ Object.defineProperty(pendingField, 'value', {
53506+ configurable: true,
53507+ get() { throw new Error('hostile response field getter'); },
53508+ });
53509+ const unreadable = await detectCaptchaOnFakePage(build, [
53510+ pendingHost,
53511+ captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53512+ ]);
53513+ assert.equal(
53514+ unreadable.responseTokenPresent,
53515+ false,
53516+ `${build}: unreadable response field did not fail closed`,
53517+ );
53518+ }
53519+ });
53520+
5340453521test('captcha detection ranks a visible nested v2 Enterprise challenge above background v3', async () => {
5340553522 const topCandidate = {
5340653523 type: 'recaptcha_v3_enterprise',
0 commit comments