Context
PR esokullu#204 ("recaptcha enterprise fixes") introduced src/{chrome,firefox}/src/agent/captcha-gate.js — a gate that blocks page-changing actions while a human-verification challenge is active. The CAPTCHA solving machinery (captcha-solver.js, captcha-frame-runtime.js) predates it; the gate is new.
The gate works and is well covered (~1,100 lines of regression tests). But every path that arms it matches English dialog copy, so it is inert on non-English pages. This issue records the language-neutral redesign.
Merging esokullu#204 was still the right call — English-only gating beats no gating. This is the follow-up to make it solid.
The problem
Three regexes in captcha-gate.js drive everything:
CHALLENGE_DIALOG_RE // captcha | security verification | verify you are human | robot check | ...
CHALLENGE_FAILURE_RE // verification failed | could not verify | ...
CHALLENGE_CONTEXT_RE // captcha | human | robot | challenge
The same pattern is duplicated 8 times across 6 files (captcha-gate.js ×2 per build — once at module scope, once inline because the detector is serialized into the page — plus captcha-frame-runtime.js and tools.js, times Chrome and Firefox). There is no i18n infrastructure in the extension: no _locales, no default_locale in the manifest.
Consequence 1 — the gate never arms. _observeCaptchaChallenge opens with detectChallengeDialog(pageContent). No English match ⇒ { gate: null } ⇒ no block. The pageGate fallback runs the same regex on the label, and _captchaMutationPreflight → detectChallengeDialogInPage uses the inline copy. So on a German, Turkish, Japanese, or Spanish page the agent can click Continue/Submit straight into an active challenge — the exact loop the gate exists to prevent.
Partial reprieve: "CAPTCHA" is a loanword in many languages, so /\bcaptcha\b/i fires sometimes. But "Sicherheitsüberprüfung", "Güvenlik doğrulaması", "Vérification de sécurité" match nothing — and Cloudflare's "Verifying you are human" is localized per visitor.
Consequence 2 — routing fails too. solve_required requires selectedCorrelated, which requires detection.selected.dialogAssociated === true, which is computed in captcha-frame-runtime.js by testing dialog text against the same English regex. So even if arming were fixed, a non-English challenge would fall through to manual_required with candidateNotCorrelated: true.
Consequence 3 — the pattern is brittle even in English. Review rounds on esokullu#204 caught a mojibake ’ for the curly apostrophe and a miss on "verify you are a human" vs "verify you are human". A heuristic that breaks on an article and a character encoding within one language will not survive a hundred.
Note the tell: normalizeChallengeLabel uses \p{L}\p{N} with the u flag, so key derivation is Unicode-aware while the matching right above it is ASCII English.
The fix: invert trigger and confirmation
Today: English regex matches a label → arm gate → call detectCaptcha to check solvability.
Instead: detectCaptcha finds a visible vendor challenge → arm gate → consult the label only as extra context.
detectCaptcha is already language-neutral — it keys on iframe hosts, data-sitekey, and response-field names, all ASCII protocol surface that does not localize. captcha-frame-runtime.js already computes a challengeFrame flag from the frame URL, and the ranker already has a 'visible challenge frame' reason. The concept exists; it is merely downstream of the regex.
Step 1 — distinguish the challenge frame from the checkbox frame
This replaces dialogAssociated, and it is strictly more precise than the text test.
reCAPTCHA's checkbox is /recaptcha/{api2,enterprise}/anchor (already detected). The actual image-grid challenge is a separate iframe at /recaptcha/{api2,enterprise}/bframe — bframe currently appears nowhere in the codebase. A visible bframe means the challenge is blocking right now; an anchor frame alone means a widget merely exists on the page.
Same split for the other vendors: hCaptcha #frame=challenge vs #frame=checkbox; Arkose /fc/gc/ enforcement frame.
This one rule does the job that three review rounds tried to get out of text: separating "an invisible v3 badge exists somewhere" from "a challenge is in the user's face."
Step 2 — read the token back
Bigger win. responseField is currently only a boolean presence check; the value is never read. But g-recaptcha-response / h-captcha-response / cf-turnstile-response being non-empty is a direct readout of solve state.
All the plumbing exists: token injection already resolves the exact field via responseFieldId / responseFieldIndex / frame selection and does element.value = token. Reading it back is the same resolution with a get instead of a set.
Caveat: a token means the widget produced one, not that the server accepted it. If the server rejects and re-renders, the challenge frame becomes visible again and the gate re-arms via Step 1 — a cleaner loop than the current renamed-dialog handling, and correct for the right reason.
Step 3 — demote the regex to an additive trigger
Keep it for in-house challenges with no recognizable vendor frame, but make it additive-only: it may arm a gate, its absence may never clear one. A wording miss then costs a missed exotic case, not a safety hole — and the 8 copies can be consolidated into one exported constant without anxiety.
If real multilingual text coverage is wanted later, a phrase table (~25 languages × ~4 phrases covers most traffic) is the honest approach. Optional polish, not part of this fix.
What this deletes
Most of the flag soup exists to compensate for not having a direct readout of solve state — the runtime currently infers "did it work?" by asking the model to re-read the accessibility tree and checking whether a dialog vanished. With Step 2 the runtime just looks.
Retire: verificationAttempts, verificationRetryRequired, verificationReadRequired, verificationFrameReadRequired, solveFailedToClearChallenge, clearedByReadOnlyVerification, remainingDialogSurface, allowGenericFailure, the two-attempt retry ladder, and authoritativeRootRead with its six-condition definition of a "complete" read. The model no longer needs a qualifying root read to clear the gate.
Net change to source should be negative in lines.
Separate but related gap: full-page interstitials
Cloudflare's managed challenge replaces the whole document — there is no [role="dialog"], so the gate misses it in every language, English included. Language-neutral tells: /cdn-cgi/challenge-platform/ subresource requests, and the cf-mitigated: challenge response header. The webNavigation permission is already held. Probably more common in practice than a localized modal.
Cost
The expensive part is the ~1,100 lines of regression tests, which encode the current semantics — many need re-expressing, not just re-running. Do not discard them. The page shapes found during esokullu#204 review are valuable fixtures: hidden dialog inside a visible iframe; invisible Enterprise widget inside a modal; unrelated v3 loader alongside a passkey prompt. Keep the scenarios, rewrite the assertions against frame visibility and token state.
Suggested first step
Land Step 2 (read the token back) alone on a fresh branch. It is the smallest change, independently useful even if nothing else moves, and it immediately reveals how much of the flag machinery was only ever standing in for a value the page was willing to hand over.
Recorded after reviewing PR esokullu#204 at b9df6eca. Suite at that commit: 1323 passed, 1 failed — the failure (repository changelog retains complete, non-empty release history, package.json 25.9.2 vs newest changelog entry 25.9.0) is inherited from the base commit and unrelated.
Context
PR esokullu#204 ("recaptcha enterprise fixes") introduced
src/{chrome,firefox}/src/agent/captcha-gate.js— a gate that blocks page-changing actions while a human-verification challenge is active. The CAPTCHA solving machinery (captcha-solver.js,captcha-frame-runtime.js) predates it; the gate is new.The gate works and is well covered (~1,100 lines of regression tests). But every path that arms it matches English dialog copy, so it is inert on non-English pages. This issue records the language-neutral redesign.
Merging esokullu#204 was still the right call — English-only gating beats no gating. This is the follow-up to make it solid.
The problem
Three regexes in
captcha-gate.jsdrive everything:The same pattern is duplicated 8 times across 6 files (
captcha-gate.js×2 per build — once at module scope, once inline because the detector is serialized into the page — pluscaptcha-frame-runtime.jsandtools.js, times Chrome and Firefox). There is no i18n infrastructure in the extension: no_locales, nodefault_localein the manifest.Consequence 1 — the gate never arms.
_observeCaptchaChallengeopens withdetectChallengeDialog(pageContent). No English match ⇒{ gate: null }⇒ no block. ThepageGatefallback runs the same regex on the label, and_captchaMutationPreflight→detectChallengeDialogInPageuses the inline copy. So on a German, Turkish, Japanese, or Spanish page the agent can click Continue/Submit straight into an active challenge — the exact loop the gate exists to prevent.Partial reprieve: "CAPTCHA" is a loanword in many languages, so
/\bcaptcha\b/ifires sometimes. But "Sicherheitsüberprüfung", "Güvenlik doğrulaması", "Vérification de sécurité" match nothing — and Cloudflare's "Verifying you are human" is localized per visitor.Consequence 2 — routing fails too.
solve_requiredrequiresselectedCorrelated, which requiresdetection.selected.dialogAssociated === true, which is computed incaptcha-frame-runtime.jsby testing dialog text against the same English regex. So even if arming were fixed, a non-English challenge would fall through tomanual_requiredwithcandidateNotCorrelated: true.Consequence 3 — the pattern is brittle even in English. Review rounds on esokullu#204 caught a mojibake
’for the curly apostrophe and a miss on "verify you are a human" vs "verify you are human". A heuristic that breaks on an article and a character encoding within one language will not survive a hundred.Note the tell:
normalizeChallengeLabeluses\p{L}\p{N}with theuflag, so key derivation is Unicode-aware while the matching right above it is ASCII English.The fix: invert trigger and confirmation
Today: English regex matches a label → arm gate → call
detectCaptchato check solvability.Instead:
detectCaptchafinds a visible vendor challenge → arm gate → consult the label only as extra context.detectCaptchais already language-neutral — it keys on iframe hosts,data-sitekey, and response-field names, all ASCII protocol surface that does not localize.captcha-frame-runtime.jsalready computes achallengeFrameflag from the frame URL, and the ranker already has a'visible challenge frame'reason. The concept exists; it is merely downstream of the regex.Step 1 — distinguish the challenge frame from the checkbox frame
This replaces
dialogAssociated, and it is strictly more precise than the text test.reCAPTCHA's checkbox is
/recaptcha/{api2,enterprise}/anchor(already detected). The actual image-grid challenge is a separate iframe at/recaptcha/{api2,enterprise}/bframe—bframecurrently appears nowhere in the codebase. A visible bframe means the challenge is blocking right now; an anchor frame alone means a widget merely exists on the page.Same split for the other vendors: hCaptcha
#frame=challengevs#frame=checkbox; Arkose/fc/gc/enforcement frame.This one rule does the job that three review rounds tried to get out of text: separating "an invisible v3 badge exists somewhere" from "a challenge is in the user's face."
Step 2 — read the token back
Bigger win.
responseFieldis currently only a boolean presence check; the value is never read. Butg-recaptcha-response/h-captcha-response/cf-turnstile-responsebeing non-empty is a direct readout of solve state.All the plumbing exists: token injection already resolves the exact field via
responseFieldId/responseFieldIndex/ frame selection and doeselement.value = token. Reading it back is the same resolution with a get instead of a set.Caveat: a token means the widget produced one, not that the server accepted it. If the server rejects and re-renders, the challenge frame becomes visible again and the gate re-arms via Step 1 — a cleaner loop than the current renamed-dialog handling, and correct for the right reason.
Step 3 — demote the regex to an additive trigger
Keep it for in-house challenges with no recognizable vendor frame, but make it additive-only: it may arm a gate, its absence may never clear one. A wording miss then costs a missed exotic case, not a safety hole — and the 8 copies can be consolidated into one exported constant without anxiety.
If real multilingual text coverage is wanted later, a phrase table (~25 languages × ~4 phrases covers most traffic) is the honest approach. Optional polish, not part of this fix.
What this deletes
Most of the flag soup exists to compensate for not having a direct readout of solve state — the runtime currently infers "did it work?" by asking the model to re-read the accessibility tree and checking whether a dialog vanished. With Step 2 the runtime just looks.
Retire:
verificationAttempts,verificationRetryRequired,verificationReadRequired,verificationFrameReadRequired,solveFailedToClearChallenge,clearedByReadOnlyVerification,remainingDialogSurface,allowGenericFailure, the two-attempt retry ladder, andauthoritativeRootReadwith its six-condition definition of a "complete" read. The model no longer needs a qualifying root read to clear the gate.Net change to source should be negative in lines.
Separate but related gap: full-page interstitials
Cloudflare's managed challenge replaces the whole document — there is no
[role="dialog"], so the gate misses it in every language, English included. Language-neutral tells:/cdn-cgi/challenge-platform/subresource requests, and thecf-mitigated: challengeresponse header. ThewebNavigationpermission is already held. Probably more common in practice than a localized modal.Cost
The expensive part is the ~1,100 lines of regression tests, which encode the current semantics — many need re-expressing, not just re-running. Do not discard them. The page shapes found during esokullu#204 review are valuable fixtures: hidden dialog inside a visible iframe; invisible Enterprise widget inside a modal; unrelated v3 loader alongside a passkey prompt. Keep the scenarios, rewrite the assertions against frame visibility and token state.
Suggested first step
Land Step 2 (read the token back) alone on a fresh branch. It is the smallest change, independently useful even if nothing else moves, and it immediately reveals how much of the flag machinery was only ever standing in for a value the page was willing to hand over.
Recorded after reviewing PR esokullu#204 at
b9df6eca. Suite at that commit: 1323 passed, 1 failed — the failure (repository changelog retains complete, non-empty release history, package.json 25.9.2 vs newest changelog entry 25.9.0) is inherited from the base commit and unrelated.