diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 40bc8d19b..e0a391eff 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -30,6 +30,8 @@ import { import * as trace from '../trace/recorder.js'; import { tracesToMarkdown } from './trace-export.js'; import { solveCaptcha, detectCaptcha, injectToken, captchaParamError, captchaTypesMatch, captchaWebsiteUrl } from './captcha-solver.js'; +import { captchaChallengeKey, detectChallengeDialog, detectChallengeDialogInPage } from './captcha-gate.js'; +import { applyCaptchaFrameVisibility } from './captcha-frame-runtime.js'; import { getRecordingStateFresh as recorderStateFresh } from '../recorder/host.js'; import { Capability, CAPABILITY_LABEL, capabilitiesFor, requiredHosts, frameHostMatches, isNetworkMutation, normalizeHost, PermissionManager, UNTRUSTED_CONTENT_TOOLS } from './permission-gate.js'; import { @@ -249,6 +251,7 @@ export class Agent extends LoopDetector { // asking the user. The agent reads the key from chrome.storage.local // at call time so rotating the key doesn't require a restart. this.captchaSolverEnabled = false; + this._captchaGateStates = new Map(); // tabId -> { key, status, publicGate, challengeFrameId? } // Pre-execution planner (Settings → Plan before Act). Default "try"; // attempts a read-only planning LLM call and degrades the current turn to // Ask/read-only if structured planning itself fails. "strict" fails closed. @@ -2677,6 +2680,445 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return BROWSER_MUTATION_TOOLS.has(toolName); } + _shouldRetryCaptchaManualGate(gate) { + const publicGate = gate?.publicGate; + const postSolveFailure = publicGate?.solveAttempted === true + || publicGate?.solveFailed === true + || publicGate?.solveFailedToClearChallenge === true; + return gate?.status === 'manual_required' + && this.captchaSolverEnabled + && !postSolveFailure + && ( + publicGate?.solverDisabled === true + || publicGate?.detectionFailed === true + ); + } + + _captchaGateBlockResult(tabId, toolName, toolArgs = {}) { + const gate = this._captchaGateStates.get(tabId); + const gatedCompletion = toolName === 'done' || toolName === 'done_json'; + const abandonmentNavigation = Agent.NAV_TOOLS.has(toolName); + const gatedAction = this._isBrowserMutationTool(toolName) + || gatedCompletion + || isNetworkMutation(toolName, toolArgs); + if ( + !gate + || !gatedAction + || abandonmentNavigation + || (gatedCompletion && gate.status === 'manual_required') + ) { + return null; + } + if (toolName === 'solve_captcha' && gate.status === 'solve_required') return null; + if (gate.status === 'manual_required') { + return { + success: false, + denied: true, + noDispatch: true, + captchaGate: true, + manualCompletionRequired: true, + captchaDiagnostics: gate.publicGate?.diagnostics || null, + error: 'A verification challenge requires manual completion. Do not dismiss or close it, and do not click Continue/Submit again. After the user completes it, first read a complete root accessibility tree with filter "visible" to confirm the dialog is gone.', + }; + } + if (gate.status === 'verification_pending') { + return { + success: false, + denied: true, + noDispatch: true, + captchaGate: true, + captchaVerificationRequired: true, + captchaDiagnostics: gate.publicGate?.diagnostics || null, + error: 'The one allowed CAPTCHA solve returned, but the verification dialog has not been confirmed cleared. Wait briefly, then read a complete root accessibility tree with filter "visible"; do not submit, dismiss, or call solve_captcha again.', + }; + } + return { + success: false, + denied: true, + noDispatch: true, + captchaGate: true, + solveCaptchaRequired: true, + captchaDiagnostics: gate.publicGate?.diagnostics || null, + error: 'A supported verification challenge is active. Call solve_captcha once before any page-changing action. Do not dismiss or close the dialog, and do not click Continue/Submit again.', + }; + } + + _clearCaptchaGateAfterNavigation(tabId, toolName, beforeUrl, afterUrl, toolResult) { + if (!Agent.NAV_TOOLS.has(toolName)) return null; + const gate = this._captchaGateStates.get(tabId); + if (!gate) return null; + const beforeDocument = this._normalizeUrlPath(beforeUrl); + const afterDocument = this._normalizeUrlPath(afterUrl); + if (!beforeDocument || !afterDocument || beforeDocument === afterDocument) return null; + const clearedGate = { + ...gate.publicGate, + status: 'cleared', + clearedByNavigation: true, + }; + this._captchaGateStates.delete(tabId); + if (toolResult && typeof toolResult === 'object') { + toolResult.captchaGate = clearedGate; + } + return clearedGate; + } + + _visibleChallengeDialogFromFrames(frameEntries, navigationFrames) { + const candidates = []; + const frameContexts = []; + for (const entry of Array.isArray(frameEntries) ? frameEntries : []) { + const frameId = Number.isInteger(entry?.frameId) ? entry.frameId : 0; + const payload = entry?.payload; + if (!payload || typeof payload !== 'object') continue; + if (payload.frameContext) { + frameContexts.push({ ...payload.frameContext, frameId }); + } + if (payload.challenge?.label) { + candidates.push({ + frameId, + frameUrl: payload.frameContext?.frameUrl || '', + visible: true, + normalCheckbox: false, + challenge: payload.challenge, + }); + } + } + const visibleCandidates = applyCaptchaFrameVisibility( + candidates, + frameContexts, + navigationFrames, + ); + const candidate = visibleCandidates.find(entry => entry.visible === true); + return candidate + ? { + ...candidate.challenge, + frameId: candidate.frameId, + frameUrl: candidate.frameUrl || '', + } + : null; + } + + async _detectChallengeDialogBeforeMutation(tabId, options = {}) { + const includeStatus = options?.includeStatus === true; + const expectedFrameId = Number.isInteger(options?.expectedFrameId) + ? options.expectedFrameId + : null; + let navigationFrames = []; + let navigationInspectionComplete = false; + try { + const discoveredFrames = await chrome.webNavigation?.getAllFrames?.({ tabId }); + if (Array.isArray(discoveredFrames)) { + navigationFrames = discoveredFrames; + navigationInspectionComplete = true; + } + } catch {} + if (!navigationFrames.length) { + navigationFrames = [{ frameId: 0, parentFrameId: -1, url: '' }]; + } + const execute = target => chrome.scripting.executeScript({ + target, + func: detectChallengeDialogInPage, + args: [{ + includeFrameContext: true, + allowGenericFailure: options?.allowGenericFailure === true, + }], + }); + const inspected = (results) => { + const entries = (results || []).map(entry => ({ + frameId: Number.isInteger(entry?.frameId) ? entry.frameId : 0, + payload: entry?.result, + })); + const inspectedFrameIds = new Set(entries + .filter(entry => entry.payload && typeof entry.payload === 'object') + .map(entry => entry.frameId)); + const expectedFrameStillExists = expectedFrameId !== null + && navigationFrames.some(frame => frame?.frameId === expectedFrameId); + const challenge = this._visibleChallengeDialogFromFrames(entries, navigationFrames); + return includeStatus + ? { + challenge, + inspectionComplete: expectedFrameId === null + || inspectedFrameIds.has(expectedFrameId) + || (navigationInspectionComplete && !expectedFrameStillExists), + } + : challenge; + }; + try { + const results = await execute({ tabId, allFrames: true }); + return inspected(results); + } catch { + try { + const results = await execute({ tabId }); + return inspected(results); + } catch { + return includeStatus + ? { challenge: null, inspectionComplete: false } + : null; + } + } + } + + async _captchaMutationPreflight(tabId, toolName, toolArgs = {}) { + const gatedCompletion = toolName === 'done' || toolName === 'done_json'; + const gatedAction = this._isBrowserMutationTool(toolName) + || gatedCompletion + || isNetworkMutation(toolName, toolArgs); + if (!gatedAction || toolName === 'solve_captcha' || Agent.NAV_TOOLS.has(toolName)) return null; + const activeGate = this._captchaGateStates.get(tabId); + if (activeGate && !this._shouldRetryCaptchaManualGate(activeGate)) return null; + const challenge = await this._detectChallengeDialogBeforeMutation(tabId); + if (!challenge?.label) return null; + let pageUrl = ''; + try { pageUrl = await this._currentUrl(tabId); } catch {} + const observation = await this._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + { + pageContent: `dialog ${JSON.stringify(String(challenge.label).slice(0, 200))}`, + pageUrl, + captchaChallengeFrameId: Number.isInteger(challenge.frameId) + ? challenge.frameId + : null, + captchaChallengeFrameUrl: challenge.frameUrl || '', + }, + {}, + ); + return observation.gate; + } + + async _observeCaptchaChallenge(tabId, toolName, toolResult, toolArgs = {}) { + if ( + toolName !== 'get_accessibility_tree' + || !toolResult + || typeof toolResult !== 'object' + || typeof toolResult.pageContent !== 'string' + || toolResult.error + ) { + return { gate: null, loopCheck: { kind: 'none' } }; + } + + const activeGate = this._captchaGateStates.get(tabId); + let challenge = detectChallengeDialog(toolResult.pageContent, { + allowGenericFailure: !!activeGate, + }); + if (!challenge && toolResult.pageGate?.surface === 'dialog' && toolResult.pageGate?.label) { + challenge = detectChallengeDialog( + `dialog ${JSON.stringify(String(toolResult.pageGate.label).slice(0, 200))}`, + { allowGenericFailure: !!activeGate }, + ); + } + let pageUrl = String(toolResult.currentUrl || toolResult.pageUrl || ''); + if (!pageUrl) { + try { pageUrl = await this._currentUrl(tabId); } catch {} + } + const treeFilter = String(toolArgs?.filter || 'all').toLowerCase(); + const requestedPage = toolArgs?.page; + const requestedMaxDepth = toolArgs?.maxDepth; + const parsedMaxDepth = Number(requestedMaxDepth); + const authoritativeRootRead = !toolArgs?.ref_id + && ( + requestedPage === undefined + || requestedPage === null + || requestedPage === '' + || Number(requestedPage) === 1 + ) + && treeFilter !== 'interactive' + && ( + requestedMaxDepth === undefined + || requestedMaxDepth === null + || requestedMaxDepth === '' + || (Number.isFinite(parsedMaxDepth) && parsedMaxDepth >= 15) + ) + && toolResult.truncated !== true + && toolResult.hasMore !== true + && toolResult.autoDegraded !== true; + if ( + !challenge + && activeGate + && authoritativeRootRead + && Number.isInteger(activeGate.challengeFrameId) + ) { + const frameInspection = await this._detectChallengeDialogBeforeMutation(tabId, { + includeStatus: true, + expectedFrameId: activeGate.challengeFrameId, + allowGenericFailure: true, + }); + if (frameInspection.challenge?.label) { + challenge = detectChallengeDialog( + `dialog ${JSON.stringify(String(frameInspection.challenge.label).slice(0, 200))}` + ); + } else if (!frameInspection.inspectionComplete) { + const guardedGate = { + ...activeGate.publicGate, + verificationFrameReadRequired: true, + }; + this._captchaGateStates.set(tabId, { + ...activeGate, + publicGate: guardedGate, + }); + toolResult.captchaGate = guardedGate; + return { gate: guardedGate, loopCheck: { kind: 'none' } }; + } + } + const loopCheck = challenge || authoritativeRootRead + ? this._checkVerificationChallengeLoop(tabId, { + pageUrl, + dialogLabel: challenge?.normalizedLabel || '', + }) + : { kind: 'none' }; + if (!challenge) { + if (activeGate && authoritativeRootRead) { + const clearedGate = { + ...activeGate.publicGate, + status: 'cleared', + clearedByReadOnlyVerification: true, + }; + this._captchaGateStates.delete(tabId); + toolResult.captchaGate = clearedGate; + return { gate: clearedGate, loopCheck }; + } + if (activeGate) { + toolResult.captchaGate = activeGate.publicGate; + return { gate: activeGate.publicGate, loopCheck }; + } + return { gate: null, loopCheck }; + } + + const key = captchaChallengeKey(pageUrl, challenge.normalizedLabel); + const existing = activeGate; + const retryManualDetection = existing?.status === 'manual_required' + && authoritativeRootRead + && this._shouldRetryCaptchaManualGate(existing); + if (existing?.status === 'manual_required' && !retryManualDetection) { + const manualGate = { + ...existing.publicGate, + status: 'manual_required', + challengeDialog: { label: challenge.label }, + }; + this._captchaGateStates.set(tabId, { + ...existing, + status: 'manual_required', + publicGate: manualGate, + }); + toolResult.captchaGate = manualGate; + return { gate: manualGate, loopCheck }; + } + if (existing?.status === 'verification_pending') { + if (!authoritativeRootRead) { + const pendingGate = { + ...existing.publicGate, + status: 'verification_pending', + challengeDialog: { label: challenge.label }, + verificationReadRequired: true, + }; + this._captchaGateStates.set(tabId, { + ...existing, + publicGate: pendingGate, + }); + toolResult.captchaGate = pendingGate; + return { gate: pendingGate, loopCheck }; + } + + const verificationAttempts = Math.max( + 0, + Number(existing.verificationAttempts) || 0 + ) + 1; + if (verificationAttempts < 2) { + const pendingGate = { + ...existing.publicGate, + status: 'verification_pending', + challengeDialog: { label: challenge.label }, + verificationAttempts, + verificationRetryRequired: true, + }; + this._captchaGateStates.set(tabId, { + ...existing, + status: 'verification_pending', + publicGate: pendingGate, + verificationAttempts, + }); + toolResult.captchaGate = pendingGate; + return { gate: pendingGate, loopCheck }; + } + + const manualGate = { + ...existing.publicGate, + status: 'manual_required', + challengeDialog: { label: challenge.label }, + solveFailedToClearChallenge: true, + verificationAttempts, + }; + this._captchaGateStates.set(tabId, { + ...existing, + status: 'manual_required', + publicGate: manualGate, + verificationAttempts, + }); + toolResult.captchaGate = manualGate; + return { gate: manualGate, loopCheck }; + } + if (existing?.key === key && !retryManualDetection) { + toolResult.captchaGate = existing.publicGate; + 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; + } + } + const diagnostics = detection?.diagnostics || failedDiagnostics || { + vendors: [], + candidateTypes: [], + supportedCandidateCount: 0, + frames: [], + }; + const unsupportedVendors = [...new Set((diagnostics.frames || []) + .filter(frame => ( + !['unknown', 'recaptcha', 'hcaptcha', 'turnstile'].includes(frame?.vendor) + && frame?.source === 'embedded' + && frame?.visible === true + )) + .map(frame => frame.vendor))]; + const selectedCorrelated = detection?.selected?.dialogAssociated === true + && detection?.selected?.frameVisible !== false; + const supported = this.captchaSolverEnabled + && !detectionFailed + && !detection?.error + && !!detection?.selected + && selectedCorrelated + && unsupportedVendors.length === 0; + const publicGate = { + status: supported ? 'solve_required' : 'manual_required', + challengeDialog: { label: challenge.label }, + diagnostics, + ...(detection?.selected?.type ? { selectedType: detection.selected.type } : {}), + ...(unsupportedVendors.length ? { unsupportedVendors } : {}), + ...(detectionFailed ? { detectionFailed: true } : {}), + ...(!this.captchaSolverEnabled ? { solverDisabled: true } : {}), + ...(detection?.error ? { selectionFailed: true } : {}), + ...(detection?.selected && !selectedCorrelated ? { candidateNotCorrelated: true } : {}), + }; + this._captchaGateStates.set(tabId, { + key, + status: publicGate.status, + publicGate, + ...(Number.isInteger(toolResult.captchaChallengeFrameId) + ? { + challengeFrameId: toolResult.captchaChallengeFrameId, + challengeFrameUrl: String(toolResult.captchaChallengeFrameUrl || ''), + } + : {}), + }); + toolResult.captchaGate = publicGate; + return { gate: publicGate, loopCheck }; + } + _browserActionFreshTurnReason(tier, toolName, toolResult) { if (toolName === 'done' && toolResult?.completionPageBlock === true) { return 'completion_page_block'; @@ -2839,13 +3281,63 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const argRepairNotice = argRepair.note || ''; // Chrome-protected pages must be rejected before any helper can touch - // the DOM or debugger. In particular, WebMCP preparation attaches CDP - // and submit/form-validation preflights execute page probes before the - // call reaches executeTool(). Keep the failure in the ordinary result - // pipeline below so tracing, loop handling, and trusted recovery notes - // still behave exactly like other tool results. + // the DOM or debugger. In particular, WebMCP preparation attaches CDP, + // submit/form-validation preflights execute page probes, and the CAPTCHA + // preflight injects a read-only dialog scan before the call reaches + // executeTool(). Keep the failure in the ordinary result pipeline below + // so tracing, loop handling, and trusted recovery notes still behave + // exactly like other tool results. const protectedPageFailure = await this._chromeProtectedPageFailure(tabId, fnName); + // A verification challenge is a runtime state boundary, not a prompt + // suggestion. Once observed, no model-authored click/close/submit or + // other page mutation may run until one supported solve completes. + const captchaPreflight = protectedPageFailure + ? null + : await this._captchaMutationPreflight(tabId, fnName, fnArgs); + if (captchaPreflight) onUpdate('captcha_gate', captchaPreflight); + const captchaGateBlock = protectedPageFailure + ? null + : this._captchaGateBlockResult(tabId, fnName, fnArgs); + if (captchaGateBlock) { + onUpdate('tool_call', { name: fnName, args: fnArgs, outcomeUnknown: false }); + onUpdate('tool_result', { name: fnName, result: captchaGateBlock }); + messages.push({ + role: 'tool', + tool_call_id: tc.id, + content: this._wrapUntrusted(fnName, this._limitToolResult(captchaGateBlock)) + + (captchaGateBlock.manualCompletionRequired + ? '\n[TRUSTED CAPTCHA GATE: Stop automation and ask the user to complete the verification manually. Do not dismiss, close, or resubmit it.]' + : captchaGateBlock.captchaVerificationRequired + ? '\n[TRUSTED CAPTCHA GATE: Read the root accessibility tree to verify whether the one solved challenge cleared. Do not submit, dismiss, or call solve_captcha again.]' + : '\n[TRUSTED CAPTCHA GATE: Call solve_captcha once now. Do not dismiss or close the verification dialog and do not click Continue/Submit.]'), + }); + const runId = this.currentRunId.get(tabId); + if (runId) { + trace.recordToolCall(runId, step, { + name: fnName, args: fnArgs, result: captchaGateBlock, latencyMs: 0, + }); + } + this._appendSyntheticToolResults( + tabId, toolCalls, toolIndex + 1, messages, onUpdate, step, + () => ({ success: false, skipped: true, error: 'skipped: the active CAPTCHA gate requires a fresh routing turn' }), + ); + onUpdate('warning', { + message: captchaGateBlock.manualCompletionRequired + ? 'Page-changing action blocked; manual verification is required.' + : captchaGateBlock.captchaVerificationRequired + ? 'Page-changing action blocked until a read-only check confirms the solved challenge cleared.' + : 'Page-changing action blocked; solve_captcha is required.', + }); + this._persist(tabId); + if (captchaGateBlock.manualCompletionRequired) { + const value = 'A verification challenge is active, but WebBrain could not safely solve a supported widget. Please complete the verification manually, then start or continue the task.'; + if (runId) trace.recordError(runId, step, 'captcha_gate', value); + return { action: 'return', value, status: 'captcha_manual_required' }; + } + return { action: 'continue' }; + } + const webMcpPreparation = protectedPageFailure ? { args: fnArgs } : await this._prepareWebMCPToolCall(tabId, fnName, fnArgs); @@ -3399,6 +3891,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const beforePath = this._normalizeUrlPath(beforeUrl); const afterPath = this._normalizeUrlPath(afterUrl); + const clearedCaptchaGate = this._clearCaptchaGateAfterNavigation( + tabId, + fnName, + beforeUrl, + afterUrl, + toolResult, + ); + if (clearedCaptchaGate) onUpdate('captcha_gate', clearedCaptchaGate); // Explicit navigation tools intentionally go somewhere. For implicit // navigation, retain the less noisy path-level warning policy: query / // hash-only SPA changes reset state but do not force a re-plan notice. @@ -3430,6 +3930,48 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } + let captchaSolveOutcome = null; + const activeCaptchaGate = this._captchaGateStates.get(tabId); + if (fnName === 'solve_captcha' && activeCaptchaGate && toolResult && typeof toolResult === 'object') { + if (toolResult.success === true && toolResult.injected === true) { + const verificationGate = { + ...activeCaptchaGate.publicGate, + status: 'verification_pending', + solveAttempted: true, + verificationAttempts: 0, + }; + captchaSolveOutcome = verificationGate; + this._captchaGateStates.set(tabId, { + ...activeCaptchaGate, + status: 'verification_pending', + publicGate: verificationGate, + verificationAttempts: 0, + }); + } else { + const manualGate = { + ...activeCaptchaGate.publicGate, + status: 'manual_required', + solveFailed: true, + }; + captchaSolveOutcome = manualGate; + this._captchaGateStates.set(tabId, { + ...activeCaptchaGate, + status: 'manual_required', + publicGate: manualGate, + }); + } + toolResult.captchaGate = captchaSolveOutcome; + onUpdate('captcha_gate', captchaSolveOutcome); + } + const captchaObservation = !toolResult?.done + ? await this._observeCaptchaChallenge(tabId, fnName, toolResult, fnArgs) + : { gate: null, loopCheck: { kind: 'none' } }; + const captchaGateDecision = captchaObservation.gate; + const challengeLoopCheck = captchaObservation.loopCheck; + if (captchaGateDecision) { + onUpdate('captcha_gate', captchaGateDecision); + } + if (!toolResult?.done) { onUpdate('tool_result', { name: fnName, result: toolResult }); } @@ -3576,9 +4118,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let effectiveKind = 'none'; let nudgeWarning = ''; let stopMessage = ''; - if (loopCheck.kind === 'stop' || coordCheck.kind === 'stop' || axReadCheck.kind === 'stop' || scrollCheck.kind === 'stop') { + if (challengeLoopCheck.kind === 'stop' || loopCheck.kind === 'stop' || coordCheck.kind === 'stop' || axReadCheck.kind === 'stop' || scrollCheck.kind === 'stop') { effectiveKind = 'stop'; - if (coordCheck.kind === 'stop') { + if (challengeLoopCheck.kind === 'stop') { + stopMessage = challengeLoopCheck.message; + } else if (coordCheck.kind === 'stop') { // Show the model's actual args, not _checkCoordClickLoop's 5px // bucket — for fractional inputs like (0.911, 0.331) the bucket // rounds to (0, 0) and the message reads as if we'd clicked the @@ -3591,9 +4135,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } else { stopMessage = loopCheck.message; } - } else if (loopCheck.kind === 'nudge' || coordCheck.kind === 'nudge' || axReadCheck.kind === 'nudge' || scrollCheck.kind === 'nudge' || deliveryCheck.kind === 'nudge') { + } else if (challengeLoopCheck.kind === 'nudge' || loopCheck.kind === 'nudge' || coordCheck.kind === 'nudge' || axReadCheck.kind === 'nudge' || scrollCheck.kind === 'nudge' || deliveryCheck.kind === 'nudge') { effectiveKind = 'nudge'; - if (coordCheck.kind === 'nudge') { + if (challengeLoopCheck.kind === 'nudge') { + nudgeWarning = challengeLoopCheck.warning; + } else if (coordCheck.kind === 'nudge') { nudgeWarning = this._coordinateClickRecoveryWarning(fnArgs, allowedToolNames); } else if (scrollCheck.kind === 'nudge') { nudgeWarning = scrollCheck.warning; @@ -3614,6 +4160,25 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d resultContent += '\n[TRUSTED RUNTIME ROUTING: Chrome blocks extension DOM/debugger access on this dashboard. Do not call another DOM, accessibility, wait, script, iframe, WebMCP, or upload_file tool here. Continue manually in the dashboard.]'; onUpdate('warning', { message: 'Chrome-protected dashboard detected; DOM automation is unavailable.' }); } + if (captchaGateDecision?.status === 'solve_required') { + resultContent += '\n[TRUSTED CAPTCHA GATE: A supported verification challenge is active. Call solve_captcha once now. Do not dismiss or close the dialog, click Continue/Submit, or use another page-changing tool until solve_captcha returns.]'; + onUpdate('warning', { message: 'Supported verification challenge detected; solve_captcha is required.' }); + } else if (captchaGateDecision?.status === 'manual_required') { + resultContent += '\n[TRUSTED CAPTCHA GATE: A verification challenge is active, but no safely selectable supported widget was detected. Stop automation and ask the user to complete it manually. Do not dismiss, close, or resubmit the challenge.]'; + onUpdate('warning', { message: 'Verification challenge requires manual completion.' }); + } else if (captchaGateDecision?.status === 'cleared') { + resultContent += '\n[TRUSTED CAPTCHA GATE: A read-only root check confirmed that the verification dialog is gone. The CAPTCHA gate is cleared. Choose any continuation or submit action only on a fresh model turn.]'; + onUpdate('warning', { message: 'Read-only verification confirmed the CAPTCHA dialog cleared.' }); + } else if (captchaGateDecision?.status === 'verification_pending') { + resultContent += captchaGateDecision.verificationRetryRequired + ? '\n[TRUSTED CAPTCHA GATE: The verification dialog was still present on the first complete post-solve check. Wait briefly, then make one final complete root accessibility-tree read with filter "visible". Do not submit, dismiss, or call solve_captcha again.]' + : '\n[TRUSTED CAPTCHA GATE: Verification is still pending. Read-only subtree, paginated, truncated, auto-degraded, or interactive-only tree reads cannot clear this gate. Wait briefly, then read a complete root accessibility tree with filter "visible".]'; + } else if (captchaSolveOutcome?.status === 'verification_pending') { + resultContent += '\n[TRUSTED CAPTCHA GATE: The supported CAPTCHA token was injected, but the challenge is not yet verified cleared. Wait briefly, then read a complete root accessibility tree with filter "visible". Until that read confirms the dialog is absent, do not submit, dismiss, or call solve_captcha again.]'; + } else if (captchaSolveOutcome?.status === 'manual_required') { + resultContent += '\n[TRUSTED CAPTCHA GATE: The one allowed automatic solve did not clear the verification challenge. Stop automation and ask the user to complete it manually. Do not retry solve_captcha, dismiss, close, or resubmit the challenge.]'; + onUpdate('warning', { message: 'Automatic CAPTCHA solve did not clear the challenge; manual completion is required.' }); + } if (nytimesPageGateFallback) { resultContent += `\n${nytimesPageGateFallback.note}`; onUpdate('warning', { @@ -3670,6 +4235,29 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } + if (captchaGateDecision?.status === 'manual_required' || captchaSolveOutcome?.status === 'manual_required') { + this._appendSyntheticToolResults( + tabId, toolCalls, toolIndex + 1, messages, onUpdate, step, + () => ({ success: false, skipped: true, error: 'skipped: manual CAPTCHA completion is required' }), + ); + const captchaRunId = this.currentRunId.get(tabId); + const value = 'A verification challenge is active, but WebBrain could not safely solve a supported widget. Please complete the verification manually, then start or continue the task.'; + if (captchaRunId) trace.recordError(captchaRunId, step, 'captcha_gate', value); + this._persist(tabId); + return { action: 'return', value, status: 'captcha_manual_required' }; + } + if (captchaGateDecision?.status === 'solve_required' + || captchaGateDecision?.status === 'cleared' + || captchaGateDecision?.status === 'verification_pending' + || captchaSolveOutcome?.status === 'verification_pending') { + this._appendSyntheticToolResults( + tabId, toolCalls, toolIndex + 1, messages, onUpdate, step, + () => ({ success: false, skipped: true, error: 'skipped: CAPTCHA routing requires a fresh verification turn' }), + ); + this._persist(tabId); + return { action: 'continue' }; + } + // A response can disappear while the page is navigating or reloading, // even for a read-only observation. Do not execute the rest of this // model-produced batch against unverified page state. Preserve provider @@ -5638,6 +6226,19 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d updatedAt: Number(entry.clarificationAuthorizationGuard.updatedAt) || Date.now(), }); } + const captchaGateState = entry.captchaGateState; + if ( + captchaGateState + && typeof captchaGateState === 'object' + && typeof captchaGateState.key === 'string' + && ['solve_required', 'verification_pending', 'manual_required'].includes( + captchaGateState.status + ) + && captchaGateState.publicGate + && typeof captchaGateState.publicGate === 'object' + ) { + this._captchaGateStates.set(tabId, captchaGateState); + } } } catch (e) { /* session storage may be unavailable */ } } @@ -5672,6 +6273,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d progressSession: this.progressSessions.get(tabId) || null, selectionGroundingScope: this.selectionGroundingScopes.get(tabId) || null, clarificationAuthorizationGuard: persistedClarificationGuard, + captchaGateState: this._captchaGateStates.get(tabId) || null, }; } @@ -8502,7 +9104,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // SYSTEM_PROMPT_ACT stands. The note unlocks the solve_captcha tool // path described there. if (this.captchaSolverEnabled) { - prompt += `\n\n[CAPTCHA SOLVER — the user has configured CapSolver. When a CAPTCHA blocks a step, call \`solve_captcha\` once (with no arguments — it auto-detects reCAPTCHA v2/v3, hCaptcha, and Cloudflare Turnstile). On success, click the form's submit button and continue. On failure, ask the user to solve it manually — do not retry solve_captcha repeatedly.]`; + prompt += `\n\n[CAPTCHA SOLVER — the user has configured CapSolver. When a CAPTCHA or verification dialog blocks a step, read the page/tree without dismissing it. The runtime will route a supported widget to \`solve_captcha\` once and block page-changing actions until a fresh root accessibility-tree read confirms the dialog cleared. If no supported widget is detected, the solve fails, or the dialog remains after solving, stop and ask the user to complete it manually; never dismiss and resubmit or retry solve_captcha.]`; } // Keep this last so the opt-in strict setting overrides loaded skills, @@ -8885,6 +9487,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._lastAxScopes.delete(tabId); this.recentNavUrls.delete(tabId); this.completionInvariants.delete(tabId); + this._captchaGateStates.delete(tabId); if (!preserveRunGuard) { this._runningTabs.delete(tabId); this.currentRunId.delete(tabId); diff --git a/src/chrome/src/agent/captcha-frame-runtime.js b/src/chrome/src/agent/captcha-frame-runtime.js index ae93a2bea..64675cc70 100644 --- a/src/chrome/src/agent/captcha-frame-runtime.js +++ b/src/chrome/src/agent/captcha-frame-runtime.js @@ -137,6 +137,24 @@ export function applyCaptchaFrameVisibility(candidates, frameContexts, navigatio visibilityByFrameId.set(frameId, visible); return visible; }; + const dialogAssociationByFrameId = new Map(); + const frameIsDialogAssociated = (frameId, visiting = new Set()) => { + if (dialogAssociationByFrameId.has(frameId)) { + return dialogAssociationByFrameId.get(frameId); + } + if (!Number.isInteger(frameId) || frameId === 0 || visiting.has(frameId)) { + return false; + } + const parentFrameId = navigationByFrameId.get(frameId)?.parentFrameId; + if (!Number.isInteger(parentFrameId) || parentFrameId === -1) return false; + const embeddingFrame = findEmbeddingFrame(frameId, parentFrameId); + visiting.add(frameId); + const associated = embeddingFrame?.dialogAssociated === true + || frameIsDialogAssociated(parentFrameId, visiting); + visiting.delete(frameId); + dialogAssociationByFrameId.set(frameId, associated); + return associated; + }; const sourceCandidates = Array.isArray(candidates) ? candidates : []; const pathIsStrictAncestor = (sourcePath, targetPath) => { @@ -249,6 +267,8 @@ export function applyCaptchaFrameVisibility(candidates, frameContexts, navigatio ...candidate, frameVisible, websiteURL: nearestHttpUrl(candidate), + dialogAssociated: candidate?.dialogAssociated === true + || frameIsDialogAssociated(candidate?.frameId), visible: candidate?.visible === true && frameVisible, normalCheckbox: candidate?.normalCheckbox === true && candidate?.visible === true && frameVisible, }; @@ -271,6 +291,7 @@ function candidateSummary(candidate) { visible: candidate?.visible === true, normalCheckbox: candidate?.normalCheckbox === true, challengeFrame: candidate?.challengeFrame === true, + dialogAssociated: candidate?.dialogAssociated === true, frameVisible: candidate?.frameVisible !== false, isInvisible: candidate?.isInvisible === true, isEnterprise: candidate?.isEnterprise === true, @@ -384,6 +405,7 @@ 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, + dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true, responseField: previous.responseField === true || candidate.responseField === true, }; for (const field of taskParameterFields) { @@ -622,20 +644,67 @@ export function detectCaptchaCandidatesInPage(scope = null) { return false; } }; - const add = (candidate) => { + const challengeDialogRe = /\b(?:(?:re|h|fun)?captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i; + const challengeDialogs = Array.from( + pageDocument.querySelectorAll('dialog, [role="dialog"], [role="alertdialog"]') + ).filter((element) => { + if (!visibleElement(element)) return false; + let labelledBy = ''; + try { + labelledBy = String(element.getAttribute?.('aria-labelledby') || '') + .split(/\s+/) + .filter(Boolean) + .map(id => pageDocument.getElementById?.(id)?.textContent || '') + .join(' '); + } catch (_) {} + return [ + element.getAttribute?.('aria-label'), + labelledBy, + element.querySelector?.('h1, h2, h3, [role="heading"]')?.textContent, + element.getAttribute?.('title'), + element.innerText, + element.textContent, + ].some(value => challengeDialogRe.test(String(value || ''))); + }); + const elementInChallengeDialog = (element) => { + if (!element) return false; + return challengeDialogs.some((dialog) => { + if (dialog === element) return true; + try { + if (typeof dialog.contains === 'function' && dialog.contains(element)) return true; + } catch (_) {} + let ancestor = element.parentElement || null; + for (let depth = 0; ancestor && depth < 20; depth += 1) { + if (ancestor === dialog) return true; + ancestor = ancestor.parentElement || null; + } + return false; + }); + }; + const add = (candidate, associationElement = null) => { if (!candidate?.type) return; + const { + responseFieldDialogAssociated, + alsoResponseFieldDialogAssociated, + ...serializableCandidate + } = candidate; candidates.push({ - ...candidate, + ...serializableCandidate, frameUrl, challengeFrame, responseField, documentTimeOrigin, + dialogAssociated: candidate.dialogAssociated === true + || responseFieldDialogAssociated === true + || alsoResponseFieldDialogAssociated === true + || elementInChallengeDialog(associationElement), }); }; const scriptElements = Array.from(pageDocument.querySelectorAll('script[src]')); - const scriptUrls = scriptElements.map(element => { - try { return element.src || ''; } catch (_) { return ''; } - }).filter(Boolean); + const scriptRecords = scriptElements.map((element) => { + try { return { element, url: element.src || '' }; } catch (_) { return { element, url: '' }; } + }).filter(record => record.url); + const scriptUrls = scriptRecords.map(record => record.url); const responseFieldIdentity = (widget, name, fallbackIndex, widgetCount) => { const selector = `textarea[name="${name}"], input[name="${name}"]`; const fields = Array.from(pageDocument.querySelectorAll(selector)); @@ -662,6 +731,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { return { ...(responseFieldId ? { responseFieldId } : {}), ...(responseFieldIndex >= 0 ? { responseFieldIndex } : {}), + ...(elementInChallengeDialog(field) ? { responseFieldDialogAssociated: true } : {}), }; }; const alsoResponseFieldIdentity = (widget, name, fallbackIndex, widgetCount) => { @@ -671,8 +741,14 @@ export function detectCaptchaCandidatesInPage(scope = null) { ...(Number.isInteger(identity.responseFieldIndex) ? { alsoResponseFieldIndex: identity.responseFieldIndex } : {}), + ...(identity.responseFieldDialogAssociated + ? { alsoResponseFieldDialogAssociated: true } + : {}), }; }; + const recaptchaResponseInChallengeDialog = Array.from(pageDocument.querySelectorAll( + 'textarea[name="g-recaptcha-response"], input[name="g-recaptcha-response"]' + )).some(elementInChallengeDialog); const hcaptchaHosts = Array.from(pageDocument.querySelectorAll( '.h-captcha[data-sitekey], div[data-hcaptcha-widget-id]' @@ -691,7 +767,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { ...responseFieldIdentity(host, 'h-captcha-response', widgetIndex, hcaptchaHosts.length), ...alsoResponseFieldIdentity(host, 'g-recaptcha-response', widgetIndex, hcaptchaHosts.length), detectedVia: 'host', - }); + }, host); } const turnstileHosts = Array.from(pageDocument.querySelectorAll( @@ -708,7 +784,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { callbackName: host.getAttribute('data-callback') || null, ...responseFieldIdentity(host, 'cf-turnstile-response', widgetIndex, turnstileHosts.length), detectedVia: 'host', - }); + }, host); } const recaptchaHosts = Array.from(pageDocument.querySelectorAll( @@ -751,7 +827,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { : {}), ...responseFieldIdentity(host, 'g-recaptcha-response', widgetIndex, recaptchaHosts.length), detectedVia: 'host', - }); + }, host); } const allIframeElements = Array.from(pageDocument.querySelectorAll('iframe')); @@ -791,7 +867,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { hcaptchaFrames.length, ), detectedVia: 'url', - }); + }, element); } continue; } @@ -810,7 +886,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { turnstileFrames.length, ), detectedVia: 'url', - }); + }, element); } continue; } @@ -846,10 +922,10 @@ export function detectCaptchaCandidatesInPage(scope = null) { recaptchaFrames.length, ), detectedVia: 'url', - }); + }, element); } - for (const url of scriptUrls) { + for (const { element, url } of scriptRecords) { if (!/recaptcha\/(api\.js|enterprise\.js)/i.test(url)) continue; const websiteKey = urlParam(url, 'render'); if (!websiteKey || websiteKey === 'explicit') continue; @@ -862,9 +938,10 @@ export function detectCaptchaCandidatesInPage(scope = null) { isEnterprise, visible: false, normalCheckbox: false, + dialogAssociated: recaptchaResponseInChallengeDialog, ...(pageAction ? { pageAction } : { note: V3_NO_ACTION_NOTE }), detectedVia: 'script', - }); + }, element); } const hasDetectedTurnstile = candidates.some(candidate => candidate.type === 'turnstile'); @@ -894,6 +971,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { loadedUrl, name, visible: visibleElement(element), + dialogAssociated: elementInChallengeDialog(element), }; }); let frameName = ''; diff --git a/src/chrome/src/agent/captcha-gate.js b/src/chrome/src/agent/captcha-gate.js new file mode 100644 index 000000000..4cffd9110 --- /dev/null +++ b/src/chrome/src/agent/captcha-gate.js @@ -0,0 +1,299 @@ +const CHALLENGE_DIALOG_RE = /\b(?:(?:re|h|fun)?captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i; +const CHALLENGE_FAILURE_RE = /\b(?:verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i; +const CHALLENGE_CONTEXT_RE = /\b(?:(?:re|h|fun)?captcha|human|robot|challenge)\b/i; + +function matchesChallengeLabel(value, allowGenericFailure = false) { + const text = String(value || ''); + return CHALLENGE_DIALOG_RE.test(text) + || ( + CHALLENGE_FAILURE_RE.test(text) + && (allowGenericFailure || CHALLENGE_CONTEXT_RE.test(text)) + ); +} + +function normalizeChallengeLabel(value) { + return String(value || '') + .normalize('NFKC') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .slice(0, 160); +} + +function parseSerializedTreeLabel(line) { + const input = String(line || ''); + const start = input.indexOf('"'); + if (start < 0) return ''; + let escaped = false; + const maxEnd = Math.min(input.length, start + 1002); + for (let index = start + 1; index < maxEnd; index += 1) { + const char = input[index]; + if (char === '"' && !escaped) { + try { + const parsed = JSON.parse(input.slice(start, index + 1)); + return typeof parsed === 'string' ? parsed.trim().slice(0, 200) : ''; + } catch { + return ''; + } + } + if (char === '\\' && !escaped) { + escaped = true; + } else { + escaped = false; + } + } + return ''; +} + +export function detectChallengeDialog(pageContent, options = null) { + const allowGenericFailure = options?.allowGenericFailure === true; + const lines = String(pageContent || '').split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const dialogMatch = line.match(/^(\s*)(?:dialog|alertdialog)(?=\s|$)/i); + if (!dialogMatch) continue; + const dialogIndent = dialogMatch[1].length; + const ownLabel = parseSerializedTreeLabel(line); + if (ownLabel && matchesChallengeLabel(ownLabel, allowGenericFailure)) { + return { + label: ownLabel, + normalizedLabel: normalizeChallengeLabel(ownLabel), + }; + } + for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) { + const childLine = lines[childIndex]; + if (!childLine.trim()) continue; + const childIndent = childLine.match(/^\s*/)?.[0].length || 0; + if (childIndent <= dialogIndent) break; + const childLabel = parseSerializedTreeLabel(childLine); + if (!childLabel || !matchesChallengeLabel(childLabel, allowGenericFailure)) continue; + return { + label: childLabel, + normalizedLabel: normalizeChallengeLabel(childLabel), + }; + } + } + return null; +} + +// Serialized into the page for a lightweight, read-only preflight before +// model-authored mutations. Keep this function self-contained. +export function detectChallengeDialogInPage(options = null) { + const includeFrameContext = options?.includeFrameContext === true; + const allowGenericFailure = options?.allowGenericFailure === true; + const pageWindow = typeof window !== 'undefined' ? window : null; + const pageLocation = pageWindow?.location + || (typeof location !== 'undefined' ? location : null); + const frameUrl = pageLocation ? String(pageLocation.href || '') : ''; + let frameName = ''; + try { + frameName = pageWindow ? String(pageWindow.name || '') : ''; + } catch {} + if (typeof document === 'undefined' || !document?.querySelectorAll) { + return includeFrameContext + ? { challenge: null, frameContext: { frameUrl, frameName, childFrames: [] } } + : null; + } + const challengeRe = /\b(?:(?:re|h|fun)?captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i; + const challengeFailureRe = /\b(?:verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i; + const challengeContextRe = /\b(?:(?:re|h|fun)?captcha|human|robot|challenge)\b/i; + const matchesChallenge = value => { + const text = String(value || ''); + return challengeRe.test(text) + || ( + challengeFailureRe.test(text) + && (allowGenericFailure || challengeContextRe.test(text)) + ); + }; + const visible = (element) => { + try { + const style = getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; + if (element.hidden || element.getAttribute?.('aria-hidden') === 'true') return false; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + const viewportWidth = typeof window !== 'undefined' && typeof window.innerWidth === 'number' + ? window.innerWidth + : (typeof innerWidth === 'number' ? innerWidth : rect.right); + const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' + ? window.innerHeight + : (typeof innerHeight === 'number' ? innerHeight : rect.bottom); + return rect.bottom > 0 + && rect.right > 0 + && rect.top < viewportHeight + && rect.left < viewportWidth; + } catch { + return false; + } + }; + const childFrames = Array.from(document.querySelectorAll('iframe')).map((element, index) => { + let loadedUrl = ''; + try { + loadedUrl = String(element.contentWindow?.location?.href || ''); + } catch {} + return { + index, + url: String(element.getAttribute?.('src') || element.src || ''), + loadedUrl, + name: String(element.getAttribute?.('name') || element.name || ''), + visible: visible(element), + }; + }); + const finish = challenge => includeFrameContext + ? { + challenge, + frameContext: { + frameUrl, + frameName, + childFrames, + }, + } + : challenge; + for (const element of document.querySelectorAll('dialog, [role="dialog"], [role="alertdialog"]')) { + if (!visible(element)) continue; + let labelledBy = ''; + try { + labelledBy = String(element.getAttribute('aria-labelledby') || '') + .split(/\s+/) + .filter(Boolean) + .map(id => document.getElementById(id)?.textContent || '') + .join(' '); + } catch {} + const values = [ + element.getAttribute?.('aria-label'), + labelledBy, + element.querySelector?.('h1, h2, h3, [role="heading"]')?.textContent, + element.getAttribute?.('title'), + element.innerText, + element.textContent, + ]; + for (const value of values) { + const text = String(value || ''); + if (!matchesChallenge(text)) continue; + // Return the dialog's full label, not the matched keyword, so the gate + // key built here matches the one built from the accessibility-tree + // dialog name and the same challenge is never keyed two ways. + const line = text.split(/\r?\n/).find(entry => matchesChallenge(entry)) || text; + const label = line.replace(/\s+/g, ' ').trim().slice(0, 200); + if (label) return finish({ label }); + } + } + return finish(null); +} + +export function captchaChallengeKey(pageUrl, normalizedLabel) { + let normalizedUrl = String(pageUrl || '').trim(); + try { + const parsed = new URL(normalizedUrl); + parsed.hash = ''; + normalizedUrl = parsed.href; + } catch { + normalizedUrl = normalizedUrl.split('#')[0]; + } + return `${normalizedUrl}\n${normalizeChallengeLabel(normalizedLabel)}`; +} + +export function sanitizeCaptchaFrameUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + try { + const parsed = new URL(raw); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return `${parsed.origin}${parsed.pathname}`; + } + if (parsed.protocol === 'about:') return `${parsed.protocol}${parsed.pathname}`; + return `${parsed.protocol}//`; + } catch { + return raw.split(/[?#]/)[0].slice(0, 300); + } +} + +export function captchaVendorFromUrl(value) { + const url = String(value || '').toLowerCase(); + if (!url) return 'unknown'; + if (/arkoselabs|funcaptcha|fc-api/.test(url)) return 'arkose'; + if (/recaptcha|google\.com\/recaptcha|recaptcha\.net/.test(url)) return 'recaptcha'; + if (/hcaptcha/.test(url)) return 'hcaptcha'; + if (/challenges\.cloudflare|turnstile/.test(url)) return 'turnstile'; + if (/geetest/.test(url)) return 'geetest'; + if (/datadome/.test(url)) return 'datadome'; + if (/mtcaptcha/.test(url)) return 'mtcaptcha'; + if (/awswaf|aws-waf|captcha\.aws/.test(url)) return 'aws_waf'; + if (/perimeterx|px-captcha/.test(url)) return 'perimeterx'; + return 'unknown'; +} + +export function buildCaptchaDiagnostics({ + candidates = [], + frameContexts = [], + navigationFrames = [], +} = {}) { + const rows = []; + const seen = new Set(); + const addFrame = ({ frameId = null, parentFrameId = null, frameUrl = '', source, visible = null }) => { + const sanitizedUrl = sanitizeCaptchaFrameUrl(frameUrl); + if (!sanitizedUrl) return; + const vendor = captchaVendorFromUrl(frameUrl); + const key = `${frameId ?? ''}|${parentFrameId ?? ''}|${sanitizedUrl}|${source}`; + if (seen.has(key) || rows.length >= 40) return; + seen.add(key); + rows.push({ + frameId: Number.isInteger(frameId) ? frameId : null, + ...(Number.isInteger(parentFrameId) ? { parentFrameId } : {}), + frameUrl: sanitizedUrl, + vendor, + source, + ...(typeof visible === 'boolean' ? { visible } : {}), + }); + }; + + for (const frame of navigationFrames || []) { + addFrame({ + frameId: frame?.frameId, + parentFrameId: frame?.parentFrameId, + frameUrl: frame?.url, + source: 'navigation', + }); + } + for (const context of frameContexts || []) { + addFrame({ + frameId: context?.frameId, + frameUrl: context?.frameUrl, + source: 'document', + }); + for (const child of context?.childFrames || []) { + addFrame({ + frameUrl: child?.loadedUrl || child?.url, + source: 'embedded', + visible: child?.visible, + }); + } + } + for (const candidate of candidates || []) { + addFrame({ + frameId: candidate?.frameId, + frameUrl: candidate?.frameUrl, + source: 'candidate', + visible: candidate?.visible, + }); + } + + const candidateTypes = [...new Set( + (candidates || []).map(candidate => String(candidate?.type || '').trim()).filter(Boolean) + )].sort(); + const candidateVendors = candidateTypes.map((type) => { + if (type.startsWith('recaptcha')) return 'recaptcha'; + if (type === 'hcaptcha') return 'hcaptcha'; + if (type === 'turnstile' || type === 'cloudflare' || type === 'cf_turnstile') return 'turnstile'; + return 'unknown'; + }); + const vendors = [...new Set( + [...rows.map(row => row.vendor), ...candidateVendors].filter(vendor => vendor !== 'unknown') + )].sort(); + return { + vendors, + candidateTypes, + supportedCandidateCount: Array.isArray(candidates) ? candidates.length : 0, + frames: rows, + }; +} diff --git a/src/chrome/src/agent/captcha-solver.js b/src/chrome/src/agent/captcha-solver.js index d4a9864a9..df59a7b74 100644 --- a/src/chrome/src/agent/captcha-solver.js +++ b/src/chrome/src/agent/captcha-solver.js @@ -22,6 +22,7 @@ import { normalizeCaptchaType, selectCaptchaCandidate, } from './captcha-frame-runtime.js'; +import { buildCaptchaDiagnostics } from './captcha-gate.js'; export { captchaTypesMatch, captchaWebsiteUrl, normalizeCaptchaType, selectCaptchaCandidate }; @@ -251,15 +252,30 @@ export async function solveCaptcha(apiKey, params) { export async function detectCaptcha(tabId, constraints = {}) { const frameTreePromise = typeof chrome.webNavigation?.getAllFrames === 'function' - ? chrome.webNavigation.getAllFrames({ tabId }).catch(() => []) + ? chrome.webNavigation.getAllFrames({ tabId }) : Promise.resolve([]); - const [results, navigationFrames] = await Promise.all([ + const [scriptAttempt, frameTreeAttempt] = await Promise.allSettled([ chrome.scripting.executeScript({ target: { tabId, allFrames: true }, func: detectCaptchaCandidatesInPage, }), frameTreePromise, ]); + const navigationFrames = frameTreeAttempt.status === 'fulfilled' + ? frameTreeAttempt.value + : []; + if (scriptAttempt.status === 'rejected') { + const cause = scriptAttempt.reason; + const error = new Error( + cause instanceof Error + ? cause.message + : String(cause || 'CAPTCHA frame inspection failed.'), + ); + if (cause instanceof Error) error.cause = cause; + error.captchaDiagnostics = buildCaptchaDiagnostics({ navigationFrames }); + throw error; + } + const results = scriptAttempt.value; const candidates = []; const frameContexts = []; for (const entry of results || []) { @@ -280,10 +296,15 @@ export async function detectCaptcha(tabId, constraints = {}) { }); } } - return selectCaptchaCandidate( - applyCaptchaFrameVisibility(candidates, frameContexts, navigationFrames), - constraints, - ); + const visibleCandidates = applyCaptchaFrameVisibility(candidates, frameContexts, navigationFrames); + return { + ...selectCaptchaCandidate(visibleCandidates, constraints), + diagnostics: buildCaptchaDiagnostics({ + candidates: visibleCandidates, + frameContexts, + navigationFrames, + }), + }; } // ─── Token injection ─────────────────────────────────────────────────── diff --git a/src/chrome/src/agent/loop-detector.js b/src/chrome/src/agent/loop-detector.js index 2f3cef251..5d485a5e9 100644 --- a/src/chrome/src/agent/loop-detector.js +++ b/src/chrome/src/agent/loop-detector.js @@ -41,6 +41,11 @@ export class LoopDetector { // unrelated noise between them, catching the "click missing its target, // model retries forever" failure mode in 2-3 attempts instead of never. this.recentCoordClicks = new Map(); // tabId -> [{ key, ts }] + // Verification overlays often allocate fresh accessibility ref ids every + // time they are dismissed and reopened. Track their semantic identity + // separately so ref churn and interleaved close/Continue calls cannot + // disguise the same challenge loop. + this.verificationChallengeStates = new Map(); // tabId -> { key, active, reopenCount } } /** @@ -193,6 +198,43 @@ export class LoopDetector { this.recentCoordClicks.delete(tabId); } + _checkVerificationChallengeLoop(tabId, { pageUrl = '', dialogLabel = '' } = {}) { + const normalizedLabel = String(dialogLabel || '') + .normalize('NFKC') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .slice(0, 160); + const previous = this.verificationChallengeStates.get(tabId); + + if (!normalizedLabel) { + if (previous?.active) { + this.verificationChallengeStates.set(tabId, { ...previous, active: false }); + } + return { kind: 'none' }; + } + + const key = `${this._normalizeUrl(pageUrl)}\n${normalizedLabel}`; + if (!previous || previous.key !== key) { + this.verificationChallengeStates.set(tabId, { key, active: true, reopenCount: 0 }); + return { kind: 'none' }; + } + if (previous.active) return { kind: 'none' }; + + const reopenCount = previous.reopenCount + 1; + this.verificationChallengeStates.set(tabId, { key, active: true, reopenCount }); + if (reopenCount >= 2) { + return { + kind: 'stop', + message: 'Stopped: the same verification dialog was dismissed and reopened repeatedly on the same page. Do not close it or resubmit the form again. Use the CAPTCHA solver when supported, or ask the user to complete the verification manually.', + }; + } + return { + kind: 'nudge', + warning: '[VERIFICATION DIALOG REOPENED: The same verification challenge returned on the same page. Do not dismiss or close it and do not click Continue/Submit again. Use solve_captcha once if supported; otherwise ask the user to complete it manually.]', + }; + } + /** * Clear everything the detector accumulated for `tabId` except the nav * arrival history, which must outlive intra-run resets so navigation @@ -212,6 +254,7 @@ export class LoopDetector { _clearRunLoopState(tabId) { this.recentNavUrls.delete(tabId); this._clearLoopState(tabId); + this.verificationChallengeStates.delete(tabId); } /** diff --git a/src/chrome/src/agent/tools.js b/src/chrome/src/agent/tools.js index d3c428504..beff19c8d 100644 --- a/src/chrome/src/agent/tools.js +++ b/src/chrome/src/agent/tools.js @@ -1634,7 +1634,7 @@ FORMS — read this: - You do NOT need verify_form for simple interactions: search boxes, single-field forms, or login forms. Use it for multi-field forms where wrong data has consequences (checkout, profile, issue creation, releases, etc.). - AFTER submitting a form, ALWAYS read the page/tree and inspect any injected verification/auto-screenshot context to confirm success BEFORE doing anything else. Do not resume other actions until you verify the submission result. Look for: a success message/toast, the newly created item appearing in a list, or a detail page for the new item. Check that the details (name, price, dates) match what you intended. - NEVER claim you created something unless you see CONFIRMATION on the page. If you see a list of items, check the creation date — if it says "2 months ago" or a past date, that is an EXISTING item, NOT something you just created. Only items with a timestamp from right now are yours. -- If you encounter any CAPTCHA, anti-bot check, or human verification challenge, the default is to STOP and ask the user to solve it — do not invent code or DOM tricks to bypass it. The single exception: when the user has configured CapSolver (you will see a "[CAPTCHA SOLVER]" note in the system prompt), call \`solve_captcha\` ONCE. If that returns success, click the form's submit button and continue. If it errors, fall back to asking the user — do not loop on solve_captcha. +- If you encounter any CAPTCHA, anti-bot check, or human verification challenge, do not dismiss, close, or resubmit it. When the user has configured CapSolver (you will see a "[CAPTCHA SOLVER]" note), let the runtime route one \`solve_captcha\` call, then read the root accessibility tree to confirm the dialog cleared before any submit. If no supported widget is detected, the solve fails, or the dialog remains, STOP and ask the user to complete it manually — never retry the solve. MODALS & DIALOGS — read this: - When a modal/dialog is open, treat the rest of the page as unreachable. click({text: ...}) and get_interactive_elements are automatically scoped to the topmost dialog, so queries for buttons behind the overlay will return "no match" — that's intentional. @@ -1838,7 +1838,7 @@ FORMS & MODALS: - Before submitting an important multi-field form (checkout, release, issue, profile), call verify_form() and compare each field to what you intended. Skip it for search/login/single-field forms. After a validation-rejected submit, verify_form only once; if checkbox state is unchanged, call set_checked directly and submit only after checkedAfter matches the desired state. - After submitting, re-read to CONFIRM success (toast, the new item appears, a detail page). Never claim you created something without on-page confirmation — an item dated "2 months ago" is pre-existing, not yours. - When a dialog is open, the rest of the page is unreachable (queries scope to the dialog). Finish it first — fill its fields and click its primary action, or dismiss it. If a dialog opened, your next click must be inside it; verify it closed before calling done. -- CAPTCHAs: STOP and ask the user, unless you see a [CAPTCHA SOLVER] note — then call solve_captcha ONCE and, on success, click submit. +- CAPTCHAs: never dismiss or resubmit a verification dialog. With a [CAPTCHA SOLVER] note, follow the runtime's one-solve route and verify clearance with a root accessibility-tree read before submitting; otherwise stop for manual completion. IFRAMES & UI-vs-API: - Cross-origin iframes (Stripe, payment widgets, embedded forms) are NOT a blocker — extension scripts bypass same-origin. Use iframe_read / iframe_click / iframe_type with a urlFilter substring. Don't refuse with "I can't access cross-origin iframes". diff --git a/src/chrome/src/cloud-runs.js b/src/chrome/src/cloud-runs.js index 89a083278..cf6774fad 100644 --- a/src/chrome/src/cloud-runs.js +++ b/src/chrome/src/cloud-runs.js @@ -147,6 +147,7 @@ function compactCloudRunForPersistence(run) { workflowId: run?.workflowId || null, traceRunId: run?.traceRunId || null, parentRunId: run?.parentRunId || null, + captchaDiagnostics: run?.captchaDiagnostics || null, tabId: run?.tabId, task: run?.task, structured: !!run?.outputSchema || run?.structured === true, @@ -191,6 +192,7 @@ function cloudSnapshot(run, { includeUpdates = true } = {}) { task: run.task, structured: run.structured ?? !!run.outputSchema, pendingInput: run.pendingInput || null, + ...(run.captchaDiagnostics ? { captchaDiagnostics: run.captchaDiagnostics } : {}), result: run.result, persistenceTruncated: run.persistenceTruncated, summary: run.summary, @@ -365,17 +367,24 @@ export function createCloudRunController({ run.summary = result.summary || run.summary; } } + if (type === 'captcha_gate') { + // Keep the latest sanitized frame/vendor snapshot at run level so it + // survives the rolling 200-update window in exported cloud traces. + run.captchaDiagnostics = { ...scrubbedData, observedAt: run.updatedAt }; + } if (type === 'clarify' && scrubbedData?.clarifyId && !TERMINAL_STATUSES.has(run.status)) { run.status = 'needs_user_input'; run.pendingInput = scrubbedData; } if (type === 'run_status' - && scrubbedData?.status === 'clarification_required' + && ['clarification_required', 'captcha_manual_required'].includes(scrubbedData?.status) && run.status !== 'aborting' && run.status !== 'aborted') { run.status = 'failed'; run.error = scrubbedData.message - || 'Cloud run stopped because explicit clarification authorization is required.'; + || (scrubbedData.status === 'captcha_manual_required' + ? 'Cloud run stopped because manual CAPTCHA completion is required.' + : 'Cloud run stopped because explicit clarification authorization is required.'); run.pendingInput = null; } if (type === 'plan_review' && run.status === 'running') { diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index b09bcffbc..ee6046a0e 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -32,6 +32,8 @@ import { import * as trace from '../trace/recorder.js'; import { tracesToMarkdown } from './trace-export.js'; import { solveCaptcha, detectCaptcha, injectToken, captchaParamError, captchaTypesMatch, captchaWebsiteUrl } from './captcha-solver.js'; +import { captchaChallengeKey, detectChallengeDialog, detectChallengeDialogInPage } from './captcha-gate.js'; +import { applyCaptchaFrameVisibility } from './captcha-frame-runtime.js'; import { Capability, CAPABILITY_LABEL, capabilitiesFor, requiredHosts, frameHostMatches, isNetworkMutation, normalizeHost, PermissionManager, UNTRUSTED_CONTENT_TOOLS } from './permission-gate.js'; import { buildPlannerMessages, @@ -209,6 +211,7 @@ export class Agent extends LoopDetector { // model to try `solve_captcha` once before falling back to asking // the user. The API key is read at call time from browser.storage. this.captchaSolverEnabled = false; + this._captchaGateStates = new Map(); // tabId -> { key, status, publicGate, challengeFrameId? } // Pre-execution planner (Settings → Plan before Act). Default "try"; // attempts a read-only planning LLM call and degrades the current turn to // Ask/read-only if structured planning itself fails. "strict" fails closed. @@ -700,6 +703,19 @@ export class Agent extends LoopDetector { updatedAt: Number(entry.clarificationAuthorizationGuard.updatedAt) || Date.now(), }); } + const captchaGateState = entry.captchaGateState; + if ( + captchaGateState + && typeof captchaGateState === 'object' + && typeof captchaGateState.key === 'string' + && ['solve_required', 'verification_pending', 'manual_required'].includes( + captchaGateState.status + ) + && captchaGateState.publicGate + && typeof captchaGateState.publicGate === 'object' + ) { + this._captchaGateStates.set(tabId, captchaGateState); + } } } catch (e) { /* session storage may be unavailable */ } } @@ -734,6 +750,7 @@ export class Agent extends LoopDetector { progressSession: this.progressSessions.get(tabId) || null, selectionGroundingScope: this.selectionGroundingScopes.get(tabId) || null, clarificationAuthorizationGuard: persistedClarificationGuard, + captchaGateState: this._captchaGateStates.get(tabId) || null, }; } @@ -2333,6 +2350,441 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return BROWSER_MUTATION_TOOLS.has(toolName); } + _shouldRetryCaptchaManualGate(gate) { + const publicGate = gate?.publicGate; + const postSolveFailure = publicGate?.solveAttempted === true + || publicGate?.solveFailed === true + || publicGate?.solveFailedToClearChallenge === true; + return gate?.status === 'manual_required' + && this.captchaSolverEnabled + && !postSolveFailure + && ( + publicGate?.solverDisabled === true + || publicGate?.detectionFailed === true + ); + } + + _captchaGateBlockResult(tabId, toolName, toolArgs = {}) { + const gate = this._captchaGateStates.get(tabId); + const gatedCompletion = toolName === 'done' || toolName === 'done_json'; + const abandonmentNavigation = Agent.NAV_TOOLS.has(toolName); + const gatedAction = this._isBrowserMutationTool(toolName) + || gatedCompletion + || isNetworkMutation(toolName, toolArgs); + if ( + !gate + || !gatedAction + || abandonmentNavigation + || (gatedCompletion && gate.status === 'manual_required') + ) { + return null; + } + if (toolName === 'solve_captcha' && gate.status === 'solve_required') return null; + if (gate.status === 'manual_required') { + return { + success: false, + denied: true, + noDispatch: true, + captchaGate: true, + manualCompletionRequired: true, + captchaDiagnostics: gate.publicGate?.diagnostics || null, + error: 'A verification challenge requires manual completion. Do not dismiss or close it, and do not click Continue/Submit again. After the user completes it, first read a complete root accessibility tree with filter "visible" to confirm the dialog is gone.', + }; + } + if (gate.status === 'verification_pending') { + return { + success: false, + denied: true, + noDispatch: true, + captchaGate: true, + captchaVerificationRequired: true, + captchaDiagnostics: gate.publicGate?.diagnostics || null, + error: 'The one allowed CAPTCHA solve returned, but the verification dialog has not been confirmed cleared. Wait briefly, then read a complete root accessibility tree with filter "visible"; do not submit, dismiss, or call solve_captcha again.', + }; + } + return { + success: false, + denied: true, + noDispatch: true, + captchaGate: true, + solveCaptchaRequired: true, + captchaDiagnostics: gate.publicGate?.diagnostics || null, + error: 'A supported verification challenge is active. Call solve_captcha once before any page-changing action. Do not dismiss or close the dialog, and do not click Continue/Submit again.', + }; + } + + _clearCaptchaGateAfterNavigation(tabId, toolName, beforeUrl, afterUrl, toolResult) { + if (!Agent.NAV_TOOLS.has(toolName)) return null; + const gate = this._captchaGateStates.get(tabId); + if (!gate) return null; + const beforeDocument = this._normalizeUrlPath(beforeUrl); + const afterDocument = this._normalizeUrlPath(afterUrl); + if (!beforeDocument || !afterDocument || beforeDocument === afterDocument) return null; + const clearedGate = { + ...gate.publicGate, + status: 'cleared', + clearedByNavigation: true, + }; + this._captchaGateStates.delete(tabId); + if (toolResult && typeof toolResult === 'object') { + toolResult.captchaGate = clearedGate; + } + return clearedGate; + } + + _visibleChallengeDialogFromFrames(frameEntries, navigationFrames) { + const candidates = []; + const frameContexts = []; + for (const entry of Array.isArray(frameEntries) ? frameEntries : []) { + const frameId = Number.isInteger(entry?.frameId) ? entry.frameId : 0; + const payload = entry?.payload; + if (!payload || typeof payload !== 'object') continue; + if (payload.frameContext) { + frameContexts.push({ ...payload.frameContext, frameId }); + } + if (payload.challenge?.label) { + candidates.push({ + frameId, + frameUrl: payload.frameContext?.frameUrl || '', + visible: true, + normalCheckbox: false, + challenge: payload.challenge, + }); + } + } + const visibleCandidates = applyCaptchaFrameVisibility( + candidates, + frameContexts, + navigationFrames, + ); + const candidate = visibleCandidates.find(entry => entry.visible === true); + return candidate + ? { + ...candidate.challenge, + frameId: candidate.frameId, + frameUrl: candidate.frameUrl || '', + } + : null; + } + + async _detectChallengeDialogBeforeMutation(tabId, options = {}) { + const includeStatus = options?.includeStatus === true; + const expectedFrameId = Number.isInteger(options?.expectedFrameId) + ? options.expectedFrameId + : null; + let navigationFrames = []; + let navigationInspectionComplete = false; + try { + const discoveredFrames = await browser.webNavigation?.getAllFrames?.({ tabId }); + if (Array.isArray(discoveredFrames)) { + navigationFrames = discoveredFrames; + navigationInspectionComplete = true; + } + } catch {} + if (!navigationFrames.length) { + navigationFrames = [{ frameId: 0, parentFrameId: -1, url: '' }]; + } + const serializedOptions = JSON.stringify({ + includeFrameContext: true, + allowGenericFailure: options?.allowGenericFailure === true, + }); + const code = `(${detectChallengeDialogInPage.toString()})(${serializedOptions})`; + const frameEntries = await Promise.all(navigationFrames.map(async frame => { + try { + const results = await browser.tabs.executeScript(tabId, { + code, + frameId: frame.frameId, + matchAboutBlank: true, + }); + return { + frameId: frame.frameId, + payload: results?.[0], + }; + } catch { + return null; + } + })); + const successfulEntries = frameEntries.filter(entry => + entry?.payload && typeof entry.payload === 'object' + ); + const challenge = this._visibleChallengeDialogFromFrames( + successfulEntries, + navigationFrames, + ); + if (!includeStatus) return challenge; + const inspectedFrameIds = new Set(successfulEntries.map(entry => entry.frameId)); + const expectedFrameStillExists = expectedFrameId !== null + && navigationFrames.some(frame => frame?.frameId === expectedFrameId); + return { + challenge, + inspectionComplete: expectedFrameId === null + || inspectedFrameIds.has(expectedFrameId) + || (navigationInspectionComplete && !expectedFrameStillExists), + }; + } + + async _captchaMutationPreflight(tabId, toolName, toolArgs = {}) { + const gatedCompletion = toolName === 'done' || toolName === 'done_json'; + const gatedAction = this._isBrowserMutationTool(toolName) + || gatedCompletion + || isNetworkMutation(toolName, toolArgs); + if (!gatedAction || toolName === 'solve_captcha' || Agent.NAV_TOOLS.has(toolName)) return null; + const activeGate = this._captchaGateStates.get(tabId); + if (activeGate && !this._shouldRetryCaptchaManualGate(activeGate)) return null; + const challenge = await this._detectChallengeDialogBeforeMutation(tabId); + if (!challenge?.label) return null; + let pageUrl = ''; + try { pageUrl = await this._currentUrl(tabId); } catch {} + const observation = await this._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + { + pageContent: `dialog ${JSON.stringify(String(challenge.label).slice(0, 200))}`, + pageUrl, + captchaChallengeFrameId: Number.isInteger(challenge.frameId) + ? challenge.frameId + : null, + captchaChallengeFrameUrl: challenge.frameUrl || '', + }, + {}, + ); + return observation.gate; + } + + async _observeCaptchaChallenge(tabId, toolName, toolResult, toolArgs = {}) { + if ( + toolName !== 'get_accessibility_tree' + || !toolResult + || typeof toolResult !== 'object' + || typeof toolResult.pageContent !== 'string' + || toolResult.error + ) { + return { gate: null, loopCheck: { kind: 'none' } }; + } + + const activeGate = this._captchaGateStates.get(tabId); + let challenge = detectChallengeDialog(toolResult.pageContent, { + allowGenericFailure: !!activeGate, + }); + if (!challenge && toolResult.pageGate?.surface === 'dialog' && toolResult.pageGate?.label) { + challenge = detectChallengeDialog( + `dialog ${JSON.stringify(String(toolResult.pageGate.label).slice(0, 200))}`, + { allowGenericFailure: !!activeGate }, + ); + } + let pageUrl = String(toolResult.currentUrl || toolResult.pageUrl || ''); + if (!pageUrl) { + try { pageUrl = await this._currentUrl(tabId); } catch {} + } + const treeFilter = String(toolArgs?.filter || 'all').toLowerCase(); + const requestedPage = toolArgs?.page; + const requestedMaxDepth = toolArgs?.maxDepth; + const parsedMaxDepth = Number(requestedMaxDepth); + const authoritativeRootRead = !toolArgs?.ref_id + && ( + requestedPage === undefined + || requestedPage === null + || requestedPage === '' + || Number(requestedPage) === 1 + ) + && treeFilter !== 'interactive' + && ( + requestedMaxDepth === undefined + || requestedMaxDepth === null + || requestedMaxDepth === '' + || (Number.isFinite(parsedMaxDepth) && parsedMaxDepth >= 15) + ) + && toolResult.truncated !== true + && toolResult.hasMore !== true + && toolResult.autoDegraded !== true; + if ( + !challenge + && activeGate + && authoritativeRootRead + && Number.isInteger(activeGate.challengeFrameId) + ) { + const frameInspection = await this._detectChallengeDialogBeforeMutation(tabId, { + includeStatus: true, + expectedFrameId: activeGate.challengeFrameId, + allowGenericFailure: true, + }); + if (frameInspection.challenge?.label) { + challenge = detectChallengeDialog( + `dialog ${JSON.stringify(String(frameInspection.challenge.label).slice(0, 200))}` + ); + } else if (!frameInspection.inspectionComplete) { + const guardedGate = { + ...activeGate.publicGate, + verificationFrameReadRequired: true, + }; + this._captchaGateStates.set(tabId, { + ...activeGate, + publicGate: guardedGate, + }); + toolResult.captchaGate = guardedGate; + return { gate: guardedGate, loopCheck: { kind: 'none' } }; + } + } + const loopCheck = challenge || authoritativeRootRead + ? this._checkVerificationChallengeLoop(tabId, { + pageUrl, + dialogLabel: challenge?.normalizedLabel || '', + }) + : { kind: 'none' }; + if (!challenge) { + if (activeGate && authoritativeRootRead) { + const clearedGate = { + ...activeGate.publicGate, + status: 'cleared', + clearedByReadOnlyVerification: true, + }; + this._captchaGateStates.delete(tabId); + toolResult.captchaGate = clearedGate; + return { gate: clearedGate, loopCheck }; + } + if (activeGate) { + toolResult.captchaGate = activeGate.publicGate; + return { gate: activeGate.publicGate, loopCheck }; + } + return { gate: null, loopCheck }; + } + + const key = captchaChallengeKey(pageUrl, challenge.normalizedLabel); + const existing = activeGate; + const retryManualDetection = existing?.status === 'manual_required' + && authoritativeRootRead + && this._shouldRetryCaptchaManualGate(existing); + if (existing?.status === 'manual_required' && !retryManualDetection) { + const manualGate = { + ...existing.publicGate, + status: 'manual_required', + challengeDialog: { label: challenge.label }, + }; + this._captchaGateStates.set(tabId, { + ...existing, + status: 'manual_required', + publicGate: manualGate, + }); + toolResult.captchaGate = manualGate; + return { gate: manualGate, loopCheck }; + } + if (existing?.status === 'verification_pending') { + if (!authoritativeRootRead) { + const pendingGate = { + ...existing.publicGate, + status: 'verification_pending', + challengeDialog: { label: challenge.label }, + verificationReadRequired: true, + }; + this._captchaGateStates.set(tabId, { + ...existing, + publicGate: pendingGate, + }); + toolResult.captchaGate = pendingGate; + return { gate: pendingGate, loopCheck }; + } + + const verificationAttempts = Math.max( + 0, + Number(existing.verificationAttempts) || 0 + ) + 1; + if (verificationAttempts < 2) { + const pendingGate = { + ...existing.publicGate, + status: 'verification_pending', + challengeDialog: { label: challenge.label }, + verificationAttempts, + verificationRetryRequired: true, + }; + this._captchaGateStates.set(tabId, { + ...existing, + status: 'verification_pending', + publicGate: pendingGate, + verificationAttempts, + }); + toolResult.captchaGate = pendingGate; + return { gate: pendingGate, loopCheck }; + } + + const manualGate = { + ...existing.publicGate, + status: 'manual_required', + challengeDialog: { label: challenge.label }, + solveFailedToClearChallenge: true, + verificationAttempts, + }; + this._captchaGateStates.set(tabId, { + ...existing, + status: 'manual_required', + publicGate: manualGate, + verificationAttempts, + }); + toolResult.captchaGate = manualGate; + return { gate: manualGate, loopCheck }; + } + if (existing?.key === key && !retryManualDetection) { + toolResult.captchaGate = existing.publicGate; + 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; + } + } + const diagnostics = detection?.diagnostics || failedDiagnostics || { + vendors: [], + candidateTypes: [], + supportedCandidateCount: 0, + frames: [], + }; + const unsupportedVendors = [...new Set((diagnostics.frames || []) + .filter(frame => ( + !['unknown', 'recaptcha', 'hcaptcha', 'turnstile'].includes(frame?.vendor) + && frame?.source === 'embedded' + && frame?.visible === true + )) + .map(frame => frame.vendor))]; + const selectedCorrelated = detection?.selected?.dialogAssociated === true + && detection?.selected?.frameVisible !== false; + const supported = this.captchaSolverEnabled + && !detectionFailed + && !detection?.error + && !!detection?.selected + && selectedCorrelated + && unsupportedVendors.length === 0; + const publicGate = { + status: supported ? 'solve_required' : 'manual_required', + challengeDialog: { label: challenge.label }, + diagnostics, + ...(detection?.selected?.type ? { selectedType: detection.selected.type } : {}), + ...(unsupportedVendors.length ? { unsupportedVendors } : {}), + ...(detectionFailed ? { detectionFailed: true } : {}), + ...(!this.captchaSolverEnabled ? { solverDisabled: true } : {}), + ...(detection?.error ? { selectionFailed: true } : {}), + ...(detection?.selected && !selectedCorrelated ? { candidateNotCorrelated: true } : {}), + }; + this._captchaGateStates.set(tabId, { + key, + status: publicGate.status, + publicGate, + ...(Number.isInteger(toolResult.captchaChallengeFrameId) + ? { + challengeFrameId: toolResult.captchaChallengeFrameId, + challengeFrameUrl: String(toolResult.captchaChallengeFrameUrl || ''), + } + : {}), + }); + toolResult.captchaGate = publicGate; + return { gate: publicGate, loopCheck }; + } + _browserActionFreshTurnReason(tier, toolName, toolResult) { if (toolName === 'done' && toolResult?.completionPageBlock === true) { return 'completion_page_block'; @@ -2492,6 +2944,51 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const fnArgs = this._toolCallArgsWithReplayMethod(tabId, fnName, argRepair.args); const argRepairNotice = argRepair.note || ''; + // A verification challenge is a runtime state boundary, not a prompt + // suggestion. Once observed, no model-authored click/close/submit or + // other page mutation may run until one supported solve completes. + const captchaPreflight = await this._captchaMutationPreflight(tabId, fnName, fnArgs); + if (captchaPreflight) onUpdate('captcha_gate', captchaPreflight); + const captchaGateBlock = this._captchaGateBlockResult(tabId, fnName, fnArgs); + if (captchaGateBlock) { + onUpdate('tool_call', { name: fnName, args: fnArgs, outcomeUnknown: false }); + onUpdate('tool_result', { name: fnName, result: captchaGateBlock }); + messages.push({ + role: 'tool', + tool_call_id: tc.id, + content: this._wrapUntrusted(fnName, this._limitToolResult(captchaGateBlock)) + + (captchaGateBlock.manualCompletionRequired + ? '\n[TRUSTED CAPTCHA GATE: Stop automation and ask the user to complete the verification manually. Do not dismiss, close, or resubmit it.]' + : captchaGateBlock.captchaVerificationRequired + ? '\n[TRUSTED CAPTCHA GATE: Read the root accessibility tree to verify whether the one solved challenge cleared. Do not submit, dismiss, or call solve_captcha again.]' + : '\n[TRUSTED CAPTCHA GATE: Call solve_captcha once now. Do not dismiss or close the verification dialog and do not click Continue/Submit.]'), + }); + const runId = this.currentRunId.get(tabId); + if (runId) { + trace.recordToolCall(runId, step, { + name: fnName, args: fnArgs, result: captchaGateBlock, latencyMs: 0, + }); + } + this._appendSyntheticToolResults( + tabId, toolCalls, toolIndex + 1, messages, onUpdate, step, + () => ({ success: false, skipped: true, error: 'skipped: the active CAPTCHA gate requires a fresh routing turn' }), + ); + onUpdate('warning', { + message: captchaGateBlock.manualCompletionRequired + ? 'Page-changing action blocked; manual verification is required.' + : captchaGateBlock.captchaVerificationRequired + ? 'Page-changing action blocked until a read-only check confirms the solved challenge cleared.' + : 'Page-changing action blocked; solve_captcha is required.', + }); + this._persist(tabId); + if (captchaGateBlock.manualCompletionRequired) { + const value = 'A verification challenge is active, but WebBrain could not safely solve a supported widget. Please complete the verification manually, then start or continue the task.'; + if (runId) trace.recordError(runId, step, 'captcha_gate', value); + return { action: 'return', value, status: 'captcha_manual_required' }; + } + return { action: 'continue' }; + } + const mediaTargetGuard = await this._downloadPublicMediaExplicitUrlGuard(tabId, fnName, fnArgs); if (mediaTargetGuard) { messages.push({ @@ -2990,6 +3487,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const beforePath = this._normalizeUrlPath(beforeUrl); const afterPath = this._normalizeUrlPath(afterUrl); + const clearedCaptchaGate = this._clearCaptchaGateAfterNavigation( + tabId, + fnName, + beforeUrl, + afterUrl, + toolResult, + ); + if (clearedCaptchaGate) onUpdate('captcha_gate', clearedCaptchaGate); // Explicit navigation tools intentionally go somewhere. For implicit // navigation, retain the less noisy path-level warning policy: query / // hash-only SPA changes reset state but do not force a re-plan notice. @@ -3021,6 +3526,48 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } + let captchaSolveOutcome = null; + const activeCaptchaGate = this._captchaGateStates.get(tabId); + if (fnName === 'solve_captcha' && activeCaptchaGate && toolResult && typeof toolResult === 'object') { + if (toolResult.success === true && toolResult.injected === true) { + const verificationGate = { + ...activeCaptchaGate.publicGate, + status: 'verification_pending', + solveAttempted: true, + verificationAttempts: 0, + }; + captchaSolveOutcome = verificationGate; + this._captchaGateStates.set(tabId, { + ...activeCaptchaGate, + status: 'verification_pending', + publicGate: verificationGate, + verificationAttempts: 0, + }); + } else { + const manualGate = { + ...activeCaptchaGate.publicGate, + status: 'manual_required', + solveFailed: true, + }; + captchaSolveOutcome = manualGate; + this._captchaGateStates.set(tabId, { + ...activeCaptchaGate, + status: 'manual_required', + publicGate: manualGate, + }); + } + toolResult.captchaGate = captchaSolveOutcome; + onUpdate('captcha_gate', captchaSolveOutcome); + } + const captchaObservation = !toolResult?.done + ? await this._observeCaptchaChallenge(tabId, fnName, toolResult, fnArgs) + : { gate: null, loopCheck: { kind: 'none' } }; + const captchaGateDecision = captchaObservation.gate; + const challengeLoopCheck = captchaObservation.loopCheck; + if (captchaGateDecision) { + onUpdate('captcha_gate', captchaGateDecision); + } + if (!toolResult?.done) { onUpdate('tool_result', { name: fnName, result: toolResult }); } @@ -3168,22 +3715,26 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let effectiveKind = 'none'; let nudgeWarning = ''; let stopMessage = ''; - if (loopCheck.kind === 'stop' || coordCheck.kind === 'stop' || axReadCheck.kind === 'stop' || scrollCheck.kind === 'stop') { + if (challengeLoopCheck.kind === 'stop' || loopCheck.kind === 'stop' || coordCheck.kind === 'stop' || axReadCheck.kind === 'stop' || scrollCheck.kind === 'stop') { effectiveKind = 'stop'; // Show the model's actual args, not _checkCoordClickLoop's 5px // bucket — for fractional inputs like (0.911, 0.331) the bucket // rounds to (0, 0) and the message reads as if we'd clicked the // top-left corner, hiding what really happened. - stopMessage = coordCheck.kind === 'stop' + stopMessage = challengeLoopCheck.kind === 'stop' + ? challengeLoopCheck.message + : coordCheck.kind === 'stop' ? `Stopped: I clicked at (or near) coordinates (${fnArgs.x}, ${fnArgs.y}) multiple times and the page never responded. That position is hitting empty space, an overlay, or the wrong element. Please give a different instruction or check the page yourself.` : scrollCheck.kind === 'stop' ? scrollCheck.message : axReadCheck.kind === 'stop' ? axReadCheck.message : loopCheck.message; - } else if (loopCheck.kind === 'nudge' || coordCheck.kind === 'nudge' || axReadCheck.kind === 'nudge' || scrollCheck.kind === 'nudge' || deliveryCheck.kind === 'nudge') { + } else if (challengeLoopCheck.kind === 'nudge' || loopCheck.kind === 'nudge' || coordCheck.kind === 'nudge' || axReadCheck.kind === 'nudge' || scrollCheck.kind === 'nudge' || deliveryCheck.kind === 'nudge') { effectiveKind = 'nudge'; - nudgeWarning = coordCheck.kind === 'nudge' + nudgeWarning = challengeLoopCheck.kind === 'nudge' + ? challengeLoopCheck.warning + : coordCheck.kind === 'nudge' ? this._coordinateClickRecoveryWarning(fnArgs, allowedToolNames) : scrollCheck.kind === 'nudge' ? scrollCheck.warning @@ -3198,6 +3749,25 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // our own trusted notes (the loop nudge), so the nudge stays outside the // box and is read as an instruction, not data. let resultContent = this._wrapUntrusted(fnName, this._limitToolResult(toolResult)); + if (captchaGateDecision?.status === 'solve_required') { + resultContent += '\n[TRUSTED CAPTCHA GATE: A supported verification challenge is active. Call solve_captcha once now. Do not dismiss or close the dialog, click Continue/Submit, or use another page-changing tool until solve_captcha returns.]'; + onUpdate('warning', { message: 'Supported verification challenge detected; solve_captcha is required.' }); + } else if (captchaGateDecision?.status === 'manual_required') { + resultContent += '\n[TRUSTED CAPTCHA GATE: A verification challenge is active, but no safely selectable supported widget was detected. Stop automation and ask the user to complete it manually. Do not dismiss, close, or resubmit the challenge.]'; + onUpdate('warning', { message: 'Verification challenge requires manual completion.' }); + } else if (captchaGateDecision?.status === 'cleared') { + resultContent += '\n[TRUSTED CAPTCHA GATE: A read-only root check confirmed that the verification dialog is gone. The CAPTCHA gate is cleared. Choose any continuation or submit action only on a fresh model turn.]'; + onUpdate('warning', { message: 'Read-only verification confirmed the CAPTCHA dialog cleared.' }); + } else if (captchaGateDecision?.status === 'verification_pending') { + resultContent += captchaGateDecision.verificationRetryRequired + ? '\n[TRUSTED CAPTCHA GATE: The verification dialog was still present on the first complete post-solve check. Wait briefly, then make one final complete root accessibility-tree read with filter "visible". Do not submit, dismiss, or call solve_captcha again.]' + : '\n[TRUSTED CAPTCHA GATE: Verification is still pending. Read-only subtree, paginated, truncated, auto-degraded, or interactive-only tree reads cannot clear this gate. Wait briefly, then read a complete root accessibility tree with filter "visible".]'; + } else if (captchaSolveOutcome?.status === 'verification_pending') { + resultContent += '\n[TRUSTED CAPTCHA GATE: The supported CAPTCHA token was injected, but the challenge is not yet verified cleared. Wait briefly, then read a complete root accessibility tree with filter "visible". Until that read confirms the dialog is absent, do not submit, dismiss, or call solve_captcha again.]'; + } else if (captchaSolveOutcome?.status === 'manual_required') { + resultContent += '\n[TRUSTED CAPTCHA GATE: The one allowed automatic solve did not clear the verification challenge. Stop automation and ask the user to complete it manually. Do not retry solve_captcha, dismiss, close, or resubmit the challenge.]'; + onUpdate('warning', { message: 'Automatic CAPTCHA solve did not clear the challenge; manual completion is required.' }); + } if (nytimesPageGateFallback) { resultContent += `\n${nytimesPageGateFallback.note}`; onUpdate('warning', { @@ -3248,6 +3818,28 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } catch {} } } + if (captchaGateDecision?.status === 'manual_required' || captchaSolveOutcome?.status === 'manual_required') { + this._appendSyntheticToolResults( + tabId, toolCalls, toolIndex + 1, messages, onUpdate, step, + () => ({ success: false, skipped: true, error: 'skipped: manual CAPTCHA completion is required' }), + ); + const captchaRunId = this.currentRunId.get(tabId); + const value = 'A verification challenge is active, but WebBrain could not safely solve a supported widget. Please complete the verification manually, then start or continue the task.'; + if (captchaRunId) trace.recordError(captchaRunId, step, 'captcha_gate', value); + this._persist(tabId); + return { action: 'return', value, status: 'captcha_manual_required' }; + } + if (captchaGateDecision?.status === 'solve_required' + || captchaGateDecision?.status === 'cleared' + || captchaGateDecision?.status === 'verification_pending' + || captchaSolveOutcome?.status === 'verification_pending') { + this._appendSyntheticToolResults( + tabId, toolCalls, toolIndex + 1, messages, onUpdate, step, + () => ({ success: false, skipped: true, error: 'skipped: CAPTCHA routing requires a fresh verification turn' }), + ); + this._persist(tabId); + return { action: 'continue' }; + } // A response can disappear while the page is navigating or reloading, // even for a read-only observation. Do not execute the rest of this // model-produced batch against unverified page state. Preserve provider @@ -7391,7 +7983,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (memoryPrompt) prompt += `\n\n${memoryPrompt}`; } if (this.captchaSolverEnabled) { - prompt += `\n\n[CAPTCHA SOLVER — the user has configured CapSolver. When a CAPTCHA blocks a step, call \`solve_captcha\` once (with no arguments — it auto-detects reCAPTCHA v2/v3, hCaptcha, and Cloudflare Turnstile). On success, click the form's submit button and continue. On failure, ask the user to solve it manually — do not retry solve_captcha repeatedly.]`; + prompt += `\n\n[CAPTCHA SOLVER — the user has configured CapSolver. When a CAPTCHA or verification dialog blocks a step, read the page/tree without dismissing it. The runtime will route a supported widget to \`solve_captcha\` once and block page-changing actions until a fresh root accessibility-tree read confirms the dialog cleared. If no supported widget is detected, the solve fails, or the dialog remains after solving, stop and ask the user to complete it manually; never dismiss and resubmit or retry solve_captcha.]`; } // Keep this last so the opt-in strict setting overrides loaded skills, // including read-only workflows that discover a secret before set_field @@ -7786,6 +8378,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d this._lastAxScopes.delete(tabId); this.recentNavUrls.delete(tabId); this.completionInvariants.delete(tabId); + this._captchaGateStates.delete(tabId); if (!preserveRunGuard) { this._runningTabs.delete(tabId); this.currentRunId.delete(tabId); diff --git a/src/firefox/src/agent/captcha-frame-runtime.js b/src/firefox/src/agent/captcha-frame-runtime.js index ae93a2bea..64675cc70 100644 --- a/src/firefox/src/agent/captcha-frame-runtime.js +++ b/src/firefox/src/agent/captcha-frame-runtime.js @@ -137,6 +137,24 @@ export function applyCaptchaFrameVisibility(candidates, frameContexts, navigatio visibilityByFrameId.set(frameId, visible); return visible; }; + const dialogAssociationByFrameId = new Map(); + const frameIsDialogAssociated = (frameId, visiting = new Set()) => { + if (dialogAssociationByFrameId.has(frameId)) { + return dialogAssociationByFrameId.get(frameId); + } + if (!Number.isInteger(frameId) || frameId === 0 || visiting.has(frameId)) { + return false; + } + const parentFrameId = navigationByFrameId.get(frameId)?.parentFrameId; + if (!Number.isInteger(parentFrameId) || parentFrameId === -1) return false; + const embeddingFrame = findEmbeddingFrame(frameId, parentFrameId); + visiting.add(frameId); + const associated = embeddingFrame?.dialogAssociated === true + || frameIsDialogAssociated(parentFrameId, visiting); + visiting.delete(frameId); + dialogAssociationByFrameId.set(frameId, associated); + return associated; + }; const sourceCandidates = Array.isArray(candidates) ? candidates : []; const pathIsStrictAncestor = (sourcePath, targetPath) => { @@ -249,6 +267,8 @@ export function applyCaptchaFrameVisibility(candidates, frameContexts, navigatio ...candidate, frameVisible, websiteURL: nearestHttpUrl(candidate), + dialogAssociated: candidate?.dialogAssociated === true + || frameIsDialogAssociated(candidate?.frameId), visible: candidate?.visible === true && frameVisible, normalCheckbox: candidate?.normalCheckbox === true && candidate?.visible === true && frameVisible, }; @@ -271,6 +291,7 @@ function candidateSummary(candidate) { visible: candidate?.visible === true, normalCheckbox: candidate?.normalCheckbox === true, challengeFrame: candidate?.challengeFrame === true, + dialogAssociated: candidate?.dialogAssociated === true, frameVisible: candidate?.frameVisible !== false, isInvisible: candidate?.isInvisible === true, isEnterprise: candidate?.isEnterprise === true, @@ -384,6 +405,7 @@ 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, + dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true, responseField: previous.responseField === true || candidate.responseField === true, }; for (const field of taskParameterFields) { @@ -622,20 +644,67 @@ export function detectCaptchaCandidatesInPage(scope = null) { return false; } }; - const add = (candidate) => { + const challengeDialogRe = /\b(?:(?:re|h|fun)?captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i; + const challengeDialogs = Array.from( + pageDocument.querySelectorAll('dialog, [role="dialog"], [role="alertdialog"]') + ).filter((element) => { + if (!visibleElement(element)) return false; + let labelledBy = ''; + try { + labelledBy = String(element.getAttribute?.('aria-labelledby') || '') + .split(/\s+/) + .filter(Boolean) + .map(id => pageDocument.getElementById?.(id)?.textContent || '') + .join(' '); + } catch (_) {} + return [ + element.getAttribute?.('aria-label'), + labelledBy, + element.querySelector?.('h1, h2, h3, [role="heading"]')?.textContent, + element.getAttribute?.('title'), + element.innerText, + element.textContent, + ].some(value => challengeDialogRe.test(String(value || ''))); + }); + const elementInChallengeDialog = (element) => { + if (!element) return false; + return challengeDialogs.some((dialog) => { + if (dialog === element) return true; + try { + if (typeof dialog.contains === 'function' && dialog.contains(element)) return true; + } catch (_) {} + let ancestor = element.parentElement || null; + for (let depth = 0; ancestor && depth < 20; depth += 1) { + if (ancestor === dialog) return true; + ancestor = ancestor.parentElement || null; + } + return false; + }); + }; + const add = (candidate, associationElement = null) => { if (!candidate?.type) return; + const { + responseFieldDialogAssociated, + alsoResponseFieldDialogAssociated, + ...serializableCandidate + } = candidate; candidates.push({ - ...candidate, + ...serializableCandidate, frameUrl, challengeFrame, responseField, documentTimeOrigin, + dialogAssociated: candidate.dialogAssociated === true + || responseFieldDialogAssociated === true + || alsoResponseFieldDialogAssociated === true + || elementInChallengeDialog(associationElement), }); }; const scriptElements = Array.from(pageDocument.querySelectorAll('script[src]')); - const scriptUrls = scriptElements.map(element => { - try { return element.src || ''; } catch (_) { return ''; } - }).filter(Boolean); + const scriptRecords = scriptElements.map((element) => { + try { return { element, url: element.src || '' }; } catch (_) { return { element, url: '' }; } + }).filter(record => record.url); + const scriptUrls = scriptRecords.map(record => record.url); const responseFieldIdentity = (widget, name, fallbackIndex, widgetCount) => { const selector = `textarea[name="${name}"], input[name="${name}"]`; const fields = Array.from(pageDocument.querySelectorAll(selector)); @@ -662,6 +731,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { return { ...(responseFieldId ? { responseFieldId } : {}), ...(responseFieldIndex >= 0 ? { responseFieldIndex } : {}), + ...(elementInChallengeDialog(field) ? { responseFieldDialogAssociated: true } : {}), }; }; const alsoResponseFieldIdentity = (widget, name, fallbackIndex, widgetCount) => { @@ -671,8 +741,14 @@ export function detectCaptchaCandidatesInPage(scope = null) { ...(Number.isInteger(identity.responseFieldIndex) ? { alsoResponseFieldIndex: identity.responseFieldIndex } : {}), + ...(identity.responseFieldDialogAssociated + ? { alsoResponseFieldDialogAssociated: true } + : {}), }; }; + const recaptchaResponseInChallengeDialog = Array.from(pageDocument.querySelectorAll( + 'textarea[name="g-recaptcha-response"], input[name="g-recaptcha-response"]' + )).some(elementInChallengeDialog); const hcaptchaHosts = Array.from(pageDocument.querySelectorAll( '.h-captcha[data-sitekey], div[data-hcaptcha-widget-id]' @@ -691,7 +767,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { ...responseFieldIdentity(host, 'h-captcha-response', widgetIndex, hcaptchaHosts.length), ...alsoResponseFieldIdentity(host, 'g-recaptcha-response', widgetIndex, hcaptchaHosts.length), detectedVia: 'host', - }); + }, host); } const turnstileHosts = Array.from(pageDocument.querySelectorAll( @@ -708,7 +784,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { callbackName: host.getAttribute('data-callback') || null, ...responseFieldIdentity(host, 'cf-turnstile-response', widgetIndex, turnstileHosts.length), detectedVia: 'host', - }); + }, host); } const recaptchaHosts = Array.from(pageDocument.querySelectorAll( @@ -751,7 +827,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { : {}), ...responseFieldIdentity(host, 'g-recaptcha-response', widgetIndex, recaptchaHosts.length), detectedVia: 'host', - }); + }, host); } const allIframeElements = Array.from(pageDocument.querySelectorAll('iframe')); @@ -791,7 +867,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { hcaptchaFrames.length, ), detectedVia: 'url', - }); + }, element); } continue; } @@ -810,7 +886,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { turnstileFrames.length, ), detectedVia: 'url', - }); + }, element); } continue; } @@ -846,10 +922,10 @@ export function detectCaptchaCandidatesInPage(scope = null) { recaptchaFrames.length, ), detectedVia: 'url', - }); + }, element); } - for (const url of scriptUrls) { + for (const { element, url } of scriptRecords) { if (!/recaptcha\/(api\.js|enterprise\.js)/i.test(url)) continue; const websiteKey = urlParam(url, 'render'); if (!websiteKey || websiteKey === 'explicit') continue; @@ -862,9 +938,10 @@ export function detectCaptchaCandidatesInPage(scope = null) { isEnterprise, visible: false, normalCheckbox: false, + dialogAssociated: recaptchaResponseInChallengeDialog, ...(pageAction ? { pageAction } : { note: V3_NO_ACTION_NOTE }), detectedVia: 'script', - }); + }, element); } const hasDetectedTurnstile = candidates.some(candidate => candidate.type === 'turnstile'); @@ -894,6 +971,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { loadedUrl, name, visible: visibleElement(element), + dialogAssociated: elementInChallengeDialog(element), }; }); let frameName = ''; diff --git a/src/firefox/src/agent/captcha-gate.js b/src/firefox/src/agent/captcha-gate.js new file mode 100644 index 000000000..4cffd9110 --- /dev/null +++ b/src/firefox/src/agent/captcha-gate.js @@ -0,0 +1,299 @@ +const CHALLENGE_DIALOG_RE = /\b(?:(?:re|h|fun)?captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i; +const CHALLENGE_FAILURE_RE = /\b(?:verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i; +const CHALLENGE_CONTEXT_RE = /\b(?:(?:re|h|fun)?captcha|human|robot|challenge)\b/i; + +function matchesChallengeLabel(value, allowGenericFailure = false) { + const text = String(value || ''); + return CHALLENGE_DIALOG_RE.test(text) + || ( + CHALLENGE_FAILURE_RE.test(text) + && (allowGenericFailure || CHALLENGE_CONTEXT_RE.test(text)) + ); +} + +function normalizeChallengeLabel(value) { + return String(value || '') + .normalize('NFKC') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .slice(0, 160); +} + +function parseSerializedTreeLabel(line) { + const input = String(line || ''); + const start = input.indexOf('"'); + if (start < 0) return ''; + let escaped = false; + const maxEnd = Math.min(input.length, start + 1002); + for (let index = start + 1; index < maxEnd; index += 1) { + const char = input[index]; + if (char === '"' && !escaped) { + try { + const parsed = JSON.parse(input.slice(start, index + 1)); + return typeof parsed === 'string' ? parsed.trim().slice(0, 200) : ''; + } catch { + return ''; + } + } + if (char === '\\' && !escaped) { + escaped = true; + } else { + escaped = false; + } + } + return ''; +} + +export function detectChallengeDialog(pageContent, options = null) { + const allowGenericFailure = options?.allowGenericFailure === true; + const lines = String(pageContent || '').split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const dialogMatch = line.match(/^(\s*)(?:dialog|alertdialog)(?=\s|$)/i); + if (!dialogMatch) continue; + const dialogIndent = dialogMatch[1].length; + const ownLabel = parseSerializedTreeLabel(line); + if (ownLabel && matchesChallengeLabel(ownLabel, allowGenericFailure)) { + return { + label: ownLabel, + normalizedLabel: normalizeChallengeLabel(ownLabel), + }; + } + for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) { + const childLine = lines[childIndex]; + if (!childLine.trim()) continue; + const childIndent = childLine.match(/^\s*/)?.[0].length || 0; + if (childIndent <= dialogIndent) break; + const childLabel = parseSerializedTreeLabel(childLine); + if (!childLabel || !matchesChallengeLabel(childLabel, allowGenericFailure)) continue; + return { + label: childLabel, + normalizedLabel: normalizeChallengeLabel(childLabel), + }; + } + } + return null; +} + +// Serialized into the page for a lightweight, read-only preflight before +// model-authored mutations. Keep this function self-contained. +export function detectChallengeDialogInPage(options = null) { + const includeFrameContext = options?.includeFrameContext === true; + const allowGenericFailure = options?.allowGenericFailure === true; + const pageWindow = typeof window !== 'undefined' ? window : null; + const pageLocation = pageWindow?.location + || (typeof location !== 'undefined' ? location : null); + const frameUrl = pageLocation ? String(pageLocation.href || '') : ''; + let frameName = ''; + try { + frameName = pageWindow ? String(pageWindow.name || '') : ''; + } catch {} + if (typeof document === 'undefined' || !document?.querySelectorAll) { + return includeFrameContext + ? { challenge: null, frameContext: { frameUrl, frameName, childFrames: [] } } + : null; + } + const challengeRe = /\b(?:(?:re|h|fun)?captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i; + const challengeFailureRe = /\b(?:verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i; + const challengeContextRe = /\b(?:(?:re|h|fun)?captcha|human|robot|challenge)\b/i; + const matchesChallenge = value => { + const text = String(value || ''); + return challengeRe.test(text) + || ( + challengeFailureRe.test(text) + && (allowGenericFailure || challengeContextRe.test(text)) + ); + }; + const visible = (element) => { + try { + const style = getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; + if (element.hidden || element.getAttribute?.('aria-hidden') === 'true') return false; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + const viewportWidth = typeof window !== 'undefined' && typeof window.innerWidth === 'number' + ? window.innerWidth + : (typeof innerWidth === 'number' ? innerWidth : rect.right); + const viewportHeight = typeof window !== 'undefined' && typeof window.innerHeight === 'number' + ? window.innerHeight + : (typeof innerHeight === 'number' ? innerHeight : rect.bottom); + return rect.bottom > 0 + && rect.right > 0 + && rect.top < viewportHeight + && rect.left < viewportWidth; + } catch { + return false; + } + }; + const childFrames = Array.from(document.querySelectorAll('iframe')).map((element, index) => { + let loadedUrl = ''; + try { + loadedUrl = String(element.contentWindow?.location?.href || ''); + } catch {} + return { + index, + url: String(element.getAttribute?.('src') || element.src || ''), + loadedUrl, + name: String(element.getAttribute?.('name') || element.name || ''), + visible: visible(element), + }; + }); + const finish = challenge => includeFrameContext + ? { + challenge, + frameContext: { + frameUrl, + frameName, + childFrames, + }, + } + : challenge; + for (const element of document.querySelectorAll('dialog, [role="dialog"], [role="alertdialog"]')) { + if (!visible(element)) continue; + let labelledBy = ''; + try { + labelledBy = String(element.getAttribute('aria-labelledby') || '') + .split(/\s+/) + .filter(Boolean) + .map(id => document.getElementById(id)?.textContent || '') + .join(' '); + } catch {} + const values = [ + element.getAttribute?.('aria-label'), + labelledBy, + element.querySelector?.('h1, h2, h3, [role="heading"]')?.textContent, + element.getAttribute?.('title'), + element.innerText, + element.textContent, + ]; + for (const value of values) { + const text = String(value || ''); + if (!matchesChallenge(text)) continue; + // Return the dialog's full label, not the matched keyword, so the gate + // key built here matches the one built from the accessibility-tree + // dialog name and the same challenge is never keyed two ways. + const line = text.split(/\r?\n/).find(entry => matchesChallenge(entry)) || text; + const label = line.replace(/\s+/g, ' ').trim().slice(0, 200); + if (label) return finish({ label }); + } + } + return finish(null); +} + +export function captchaChallengeKey(pageUrl, normalizedLabel) { + let normalizedUrl = String(pageUrl || '').trim(); + try { + const parsed = new URL(normalizedUrl); + parsed.hash = ''; + normalizedUrl = parsed.href; + } catch { + normalizedUrl = normalizedUrl.split('#')[0]; + } + return `${normalizedUrl}\n${normalizeChallengeLabel(normalizedLabel)}`; +} + +export function sanitizeCaptchaFrameUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + try { + const parsed = new URL(raw); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return `${parsed.origin}${parsed.pathname}`; + } + if (parsed.protocol === 'about:') return `${parsed.protocol}${parsed.pathname}`; + return `${parsed.protocol}//`; + } catch { + return raw.split(/[?#]/)[0].slice(0, 300); + } +} + +export function captchaVendorFromUrl(value) { + const url = String(value || '').toLowerCase(); + if (!url) return 'unknown'; + if (/arkoselabs|funcaptcha|fc-api/.test(url)) return 'arkose'; + if (/recaptcha|google\.com\/recaptcha|recaptcha\.net/.test(url)) return 'recaptcha'; + if (/hcaptcha/.test(url)) return 'hcaptcha'; + if (/challenges\.cloudflare|turnstile/.test(url)) return 'turnstile'; + if (/geetest/.test(url)) return 'geetest'; + if (/datadome/.test(url)) return 'datadome'; + if (/mtcaptcha/.test(url)) return 'mtcaptcha'; + if (/awswaf|aws-waf|captcha\.aws/.test(url)) return 'aws_waf'; + if (/perimeterx|px-captcha/.test(url)) return 'perimeterx'; + return 'unknown'; +} + +export function buildCaptchaDiagnostics({ + candidates = [], + frameContexts = [], + navigationFrames = [], +} = {}) { + const rows = []; + const seen = new Set(); + const addFrame = ({ frameId = null, parentFrameId = null, frameUrl = '', source, visible = null }) => { + const sanitizedUrl = sanitizeCaptchaFrameUrl(frameUrl); + if (!sanitizedUrl) return; + const vendor = captchaVendorFromUrl(frameUrl); + const key = `${frameId ?? ''}|${parentFrameId ?? ''}|${sanitizedUrl}|${source}`; + if (seen.has(key) || rows.length >= 40) return; + seen.add(key); + rows.push({ + frameId: Number.isInteger(frameId) ? frameId : null, + ...(Number.isInteger(parentFrameId) ? { parentFrameId } : {}), + frameUrl: sanitizedUrl, + vendor, + source, + ...(typeof visible === 'boolean' ? { visible } : {}), + }); + }; + + for (const frame of navigationFrames || []) { + addFrame({ + frameId: frame?.frameId, + parentFrameId: frame?.parentFrameId, + frameUrl: frame?.url, + source: 'navigation', + }); + } + for (const context of frameContexts || []) { + addFrame({ + frameId: context?.frameId, + frameUrl: context?.frameUrl, + source: 'document', + }); + for (const child of context?.childFrames || []) { + addFrame({ + frameUrl: child?.loadedUrl || child?.url, + source: 'embedded', + visible: child?.visible, + }); + } + } + for (const candidate of candidates || []) { + addFrame({ + frameId: candidate?.frameId, + frameUrl: candidate?.frameUrl, + source: 'candidate', + visible: candidate?.visible, + }); + } + + const candidateTypes = [...new Set( + (candidates || []).map(candidate => String(candidate?.type || '').trim()).filter(Boolean) + )].sort(); + const candidateVendors = candidateTypes.map((type) => { + if (type.startsWith('recaptcha')) return 'recaptcha'; + if (type === 'hcaptcha') return 'hcaptcha'; + if (type === 'turnstile' || type === 'cloudflare' || type === 'cf_turnstile') return 'turnstile'; + return 'unknown'; + }); + const vendors = [...new Set( + [...rows.map(row => row.vendor), ...candidateVendors].filter(vendor => vendor !== 'unknown') + )].sort(); + return { + vendors, + candidateTypes, + supportedCandidateCount: Array.isArray(candidates) ? candidates.length : 0, + frames: rows, + }; +} diff --git a/src/firefox/src/agent/captcha-solver.js b/src/firefox/src/agent/captcha-solver.js index 7721de969..b72bbf56e 100644 --- a/src/firefox/src/agent/captcha-solver.js +++ b/src/firefox/src/agent/captcha-solver.js @@ -19,6 +19,7 @@ import { normalizeCaptchaType, selectCaptchaCandidate, } from './captcha-frame-runtime.js'; +import { buildCaptchaDiagnostics } from './captcha-gate.js'; export { captchaTypesMatch, captchaWebsiteUrl, normalizeCaptchaType, selectCaptchaCandidate }; @@ -235,7 +236,15 @@ export async function detectCaptcha(tabId, constraints = {}) { return false; } }; - const visit = (currentDocument, currentWindow, currentResult, path, ancestorsVisible, depth) => { + const visit = ( + currentDocument, + currentWindow, + currentResult, + path, + ancestorsVisible, + ancestorsDialogAssociated, + depth, + ) => { if (!currentDocument || depth > 12 || seenDocuments.has(currentDocument)) return; seenDocuments.add(currentDocument); const elements = Array.from(currentDocument.querySelectorAll('iframe')); @@ -257,6 +266,8 @@ export async function detectCaptcha(tabId, constraints = {}) { const childResult = detect({ document: childDocument, window: childWindow }); const childContext = childResult?.frameContext || {}; const childVisible = ancestorsVisible && childFrames[index]?.visible === true; + const childDialogAssociated = ancestorsDialogAssociated + || childFrames[index]?.dialogAssociated === true; const nextPath = [...path, { index, frameUrl: childContext.frameUrl || childUrl, @@ -267,12 +278,21 @@ export async function detectCaptcha(tabId, constraints = {}) { ...candidate, framePath: nextPath, frameVisibleWithinAnchor: childVisible, + dialogAssociated: candidate.dialogAssociated === true || childDialogAssociated, }); } - visit(childDocument, childWindow, childResult, nextPath, childVisible, depth + 1); + visit( + childDocument, + childWindow, + childResult, + nextPath, + childVisible, + childDialogAssociated, + depth + 1, + ); }); }; - visit(rootDocument, rootWindow, direct, [], true, 0); + visit(rootDocument, rootWindow, direct, [], true, false, 0); return { direct, inheritedCandidates }; })()`; const batches = await Promise.all(frames.map(async frame => { @@ -321,10 +341,15 @@ export async function detectCaptcha(tabId, constraints = {}) { )); const candidates = [...directCandidates, ...inheritedCandidates]; const frameContexts = batches.map(batch => batch.frameContext).filter(Boolean); - return selectCaptchaCandidate( - applyCaptchaFrameVisibility(candidates, frameContexts, frames), - constraints, - ); + const visibleCandidates = applyCaptchaFrameVisibility(candidates, frameContexts, frames); + return { + ...selectCaptchaCandidate(visibleCandidates, constraints), + diagnostics: buildCaptchaDiagnostics({ + candidates: visibleCandidates, + frameContexts, + navigationFrames: frames, + }), + }; } export async function injectToken(tabId, { diff --git a/src/firefox/src/agent/loop-detector.js b/src/firefox/src/agent/loop-detector.js index 2f3cef251..5d485a5e9 100644 --- a/src/firefox/src/agent/loop-detector.js +++ b/src/firefox/src/agent/loop-detector.js @@ -41,6 +41,11 @@ export class LoopDetector { // unrelated noise between them, catching the "click missing its target, // model retries forever" failure mode in 2-3 attempts instead of never. this.recentCoordClicks = new Map(); // tabId -> [{ key, ts }] + // Verification overlays often allocate fresh accessibility ref ids every + // time they are dismissed and reopened. Track their semantic identity + // separately so ref churn and interleaved close/Continue calls cannot + // disguise the same challenge loop. + this.verificationChallengeStates = new Map(); // tabId -> { key, active, reopenCount } } /** @@ -193,6 +198,43 @@ export class LoopDetector { this.recentCoordClicks.delete(tabId); } + _checkVerificationChallengeLoop(tabId, { pageUrl = '', dialogLabel = '' } = {}) { + const normalizedLabel = String(dialogLabel || '') + .normalize('NFKC') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .slice(0, 160); + const previous = this.verificationChallengeStates.get(tabId); + + if (!normalizedLabel) { + if (previous?.active) { + this.verificationChallengeStates.set(tabId, { ...previous, active: false }); + } + return { kind: 'none' }; + } + + const key = `${this._normalizeUrl(pageUrl)}\n${normalizedLabel}`; + if (!previous || previous.key !== key) { + this.verificationChallengeStates.set(tabId, { key, active: true, reopenCount: 0 }); + return { kind: 'none' }; + } + if (previous.active) return { kind: 'none' }; + + const reopenCount = previous.reopenCount + 1; + this.verificationChallengeStates.set(tabId, { key, active: true, reopenCount }); + if (reopenCount >= 2) { + return { + kind: 'stop', + message: 'Stopped: the same verification dialog was dismissed and reopened repeatedly on the same page. Do not close it or resubmit the form again. Use the CAPTCHA solver when supported, or ask the user to complete the verification manually.', + }; + } + return { + kind: 'nudge', + warning: '[VERIFICATION DIALOG REOPENED: The same verification challenge returned on the same page. Do not dismiss or close it and do not click Continue/Submit again. Use solve_captcha once if supported; otherwise ask the user to complete it manually.]', + }; + } + /** * Clear everything the detector accumulated for `tabId` except the nav * arrival history, which must outlive intra-run resets so navigation @@ -212,6 +254,7 @@ export class LoopDetector { _clearRunLoopState(tabId) { this.recentNavUrls.delete(tabId); this._clearLoopState(tabId); + this.verificationChallengeStates.delete(tabId); } /** diff --git a/src/firefox/src/agent/tools.js b/src/firefox/src/agent/tools.js index 38ffa5d60..33611c55e 100644 --- a/src/firefox/src/agent/tools.js +++ b/src/firefox/src/agent/tools.js @@ -1460,7 +1460,7 @@ FORMS — read this: - You do NOT need verify_form for simple interactions: search boxes, single-field forms, or login forms. Use it for multi-field forms where wrong data has consequences (checkout, profile, issue creation, releases, etc.). - AFTER submitting a form, ALWAYS read the page/tree and inspect any injected verification/auto-screenshot context to confirm success BEFORE doing anything else. Do not resume other actions until you verify the submission result. Look for: a success message/toast, the newly created item appearing in a list, or a detail page for the new item. Check that the details (name, price, dates) match what you intended. - NEVER claim you created something unless you see CONFIRMATION on the page. If you see a list of items, check the creation date — if it says "2 months ago" or a past date, that is an EXISTING item, NOT something you just created. Only items with a timestamp from right now are yours. -- If you encounter any CAPTCHA, anti-bot check, or human verification challenge, the default is to STOP and ask the user to solve it — do not invent code or DOM tricks to bypass it. The single exception: when the user has configured CapSolver (you will see a "[CAPTCHA SOLVER]" note in the system prompt), call \`solve_captcha\` ONCE. If that returns success, click the form's submit button and continue. If it errors, fall back to asking the user — do not loop on solve_captcha. +- If you encounter any CAPTCHA, anti-bot check, or human verification challenge, do not dismiss, close, or resubmit it. When the user has configured CapSolver (you will see a "[CAPTCHA SOLVER]" note), let the runtime route one \`solve_captcha\` call, then read the root accessibility tree to confirm the dialog cleared before any submit. If no supported widget is detected, the solve fails, or the dialog remains, STOP and ask the user to complete it manually — never retry the solve. MODALS & DIALOGS — read this: - When a modal/dialog is open, treat the rest of the page as unreachable. click({text: ...}) is automatically scoped to the topmost dialog, so text queries for buttons behind the overlay will return "no match" — that's intentional. @@ -1595,7 +1595,7 @@ FORMS & MODALS: - Before submitting an important multi-field form (checkout, release, issue, profile), call verify_form() and compare each field to what you intended. Skip it for search/login/single-field forms. After a validation-rejected submit, verify_form only once; if checkbox state is unchanged, call set_checked directly and submit only after checkedAfter matches the desired state. - After submitting, re-read or inspect injected verification context to CONFIRM success (toast, the new item appears, a detail page). Never claim you created something without on-page confirmation — an item dated "2 months ago" is pre-existing, not yours. - When a dialog is open, the rest of the page is unreachable (queries scope to the dialog). Finish it first — fill its fields and click its primary action, or dismiss it. If a dialog opened, your next click must be inside it; verify it closed before calling done. -- CAPTCHAs: STOP and ask the user, unless you see a [CAPTCHA SOLVER] note — then call solve_captcha ONCE and, on success, click submit. +- CAPTCHAs: never dismiss or resubmit a verification dialog. With a [CAPTCHA SOLVER] note, follow the runtime's one-solve route and verify clearance with a root accessibility-tree read before submitting; otherwise stop for manual completion. IFRAMES & UI-vs-API: - Cross-origin iframes (Stripe, payment widgets, embedded forms) are NOT a blocker — extension scripts bypass same-origin. Use iframe_read / iframe_click / iframe_type with a urlFilter substring. Don't refuse with "I can't access cross-origin iframes". diff --git a/test/run.js b/test/run.js index 8dab97aea..d8b792cf5 100644 --- a/test/run.js +++ b/test/run.js @@ -436,6 +436,12 @@ const { LoopDetector: LoopDetectorCh } = await import( const { LoopDetector: LoopDetectorFx } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/agent/loop-detector.js').replace(/\\/g, '/') ); +const CaptchaGateCh = await import( + 'file://' + path.join(ROOT, 'src/chrome/src/agent/captcha-gate.js').replace(/\\/g, '/') +); +const CaptchaGateFx = await import( + 'file://' + path.join(ROOT, 'src/firefox/src/agent/captcha-gate.js').replace(/\\/g, '/') +); // The mutating-tool surface differs per build, so it lives outside the // byte-identical loop-detector module. Import the real sets rather than // restating them here — a hand-copied list is exactly the drift this suite @@ -4660,6 +4666,567 @@ test('LoopDetector is production code shared by both browser agents', () => { assert.equal(LoopDetectorCh.prototype._isBrowserMutationTool.call({}, 'click'), false); }); +test('verification dialog loop survives the exact Dismiss, Close, Continue sequence and changing refs', () => { + const builds = [ + ['chrome', LoopDetectorCh, CaptchaGateCh], + ['firefox', LoopDetectorFx, CaptchaGateFx], + ]; + const pageUrl = 'https://www.linkedin.com/signup/cold-join'; + const challengeTree = ref => `[open overlays — rendered first so they survive truncation] +dialog "Security verification" [ref_${ref}] + banner [ref_${ref + 1}] + heading "Security verification" [ref_${ref + 2}] + button "Dismiss" [ref_${ref + 3}] type="button" +[/open overlays] +button "Continue" [ref_3] type="submit"`; + const closeAlertTree = ref => `[open overlays — rendered first so they survive truncation] +alert "Your security verification session was closed" [ref_${ref}] + button "Close" [ref_${ref + 1}] type="button" +[/open overlays] +button "Continue" [ref_3] type="submit"`; + + for (const [label, Detector, gate] of builds) { + const detector = new Detector(); + const observe = (tree) => { + const challenge = gate.detectChallengeDialog(tree); + return detector._checkVerificationChallengeLoop('verification-tab', { + pageUrl, + dialogLabel: challenge?.normalizedLabel || '', + }); + }; + + // Exact captured sequence: observe challenge → Dismiss → observe Close + // alert → Close → Continue → same challenge with newly allocated refs. + assert.equal(observe(challengeTree(50486214628932)).kind, 'none', `${label}: initial dialog`); + assert.equal(observe(closeAlertTree(50486214628949)).kind, 'none', `${label}: Dismiss then Close alert`); + detector._clearPageLoopState('verification-tab'); + assert.equal(observe(challengeTree(50486214628968)).kind, 'nudge', `${label}: first Continue reopen after document replacement`); + assert.equal(observe(closeAlertTree(50486214628987)).kind, 'none', `${label}: second Dismiss then Close alert`); + const stopped = observe(challengeTree(50486214629006)); + assert.equal(stopped.kind, 'stop', `${label}: second Continue reopen must stop`); + assert.match(stopped.message, /same verification dialog.*same page/i, `${label}: semantic stop reason missing`); + } +}); + +test('CAPTCHA dialog parsing handles descendant-only labels and escaped quotes', () => { + const cases = [ + { + tree: 'dialog [ref_1]\n heading "Security verification" [ref_2]\n button "Dismiss" [ref_3]', + label: 'Security verification', + normalized: /security verification/, + }, + { + tree: String.raw`dialog "Complete \"Security verification\" now" [ref_4]`, + label: 'Complete "Security verification" now', + normalized: /security verification/, + }, + { + tree: 'dialog "Verify that you\u2019re a human" [ref_5]', + label: 'Verify that you\u2019re a human', + normalized: /verify that you re a human/, + }, + { + tree: 'dialog "reCAPTCHA challenge" [ref_8]', + label: 'reCAPTCHA challenge', + normalized: /recaptcha challenge/, + }, + ]; + for (const [build, gate] of [['chrome', CaptchaGateCh], ['firefox', CaptchaGateFx]]) { + for (const example of cases) { + const challenge = gate.detectChallengeDialog(example.tree); + assert.equal(challenge?.label, example.label, `${build}: failed to parse ${example.tree}`); + assert.match(challenge?.normalizedLabel || '', example.normalized, `${build}: normalized challenge label missing`); + } + const genericFailure = 'dialog "Email verification failed" [ref_6]'; + assert.equal(gate.detectChallengeDialog(genericFailure), null, `${build}: generic application verification failure armed a CAPTCHA gate`); + assert.equal( + gate.detectChallengeDialog(genericFailure, { allowGenericFailure: true })?.label, + 'Email verification failed', + `${build}: active CAPTCHA could not retain a renamed failure dialog`, + ); + assert.equal( + gate.detectChallengeDialog('dialog "CAPTCHA verification failed" [ref_7]')?.label, + 'CAPTCHA verification failed', + `${build}: contextual CAPTCHA failure was missed`, + ); + } +}); + +test('CAPTCHA mutation preflight ignores hidden and off-viewport verification dialogs', async () => { + for (const [build, gate] of [['chrome', CaptchaGateCh], ['firefox', CaptchaGateFx]]) { + const hiddenCases = [ + captchaEl('div', { + role: 'dialog', + innerText: 'Security verification', + hidden: true, + }), + captchaEl('div', { + role: 'dialog', + innerText: 'Security verification', + 'aria-hidden': 'true', + }), + captchaEl('div', { + role: 'dialog', + innerText: 'Security verification', + rect: { left: 0, top: 800, right: 100, bottom: 840, width: 100, height: 40 }, + }), + ]; + for (const dialog of hiddenCases) { + await withCaptchaFakePage(build, [dialog], async () => { + assert.equal(gate.detectChallengeDialogInPage(), null, `${build}: inactive dialog armed the mutation preflight`); + }); + } + await withCaptchaFakePage(build, [ + captchaEl('div', { role: 'dialog', innerText: 'Verify you are a human' }), + ], async () => { + assert.equal(gate.detectChallengeDialogInPage()?.label, 'Verify you are a human', `${build}: article-bearing challenge dialog was missed`); + }); + await withCaptchaFakePage(build, [ + captchaEl('div', { role: 'dialog', innerText: 'reCAPTCHA' }), + ], async () => { + assert.equal(gate.detectChallengeDialogInPage()?.label, 'reCAPTCHA', `${build}: branded reCAPTCHA dialog was missed`); + }); + await withCaptchaFakePage(build, [ + captchaEl('div', { role: 'dialog', innerText: 'Email verification failed' }), + ], async () => { + assert.equal(gate.detectChallengeDialogInPage(), null, `${build}: generic application failure armed preflight`); + assert.equal( + gate.detectChallengeDialogInPage({ allowGenericFailure: true })?.label, + 'Email verification failed', + `${build}: active-gate failure context was ignored`, + ); + }); + } +}); + +test('CAPTCHA challenge gate blocks dismiss/resubmit mutations but allows the one solve', () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const agent = new AgentClass({}); + agent._captchaGateStates.set(88, { + key: 'https://example.test/signup\nsecurity verification', + status: 'solve_required', + publicGate: { status: 'solve_required', diagnostics: { frames: [] } }, + }); + for (const toolName of ['click', 'click_ax', 'press_keys', 'set_field', 'iframe_click']) { + const blocked = agent._captchaGateBlockResult(88, toolName); + assert.equal(blocked?.solveCaptchaRequired, true, `${label}: ${toolName} escaped the gate`); + assert.equal(blocked?.noDispatch, true, `${label}: ${toolName} block must prove no dispatch`); + } + assert.equal(agent._captchaGateBlockResult(88, 'solve_captcha'), null, `${label}: solve_captcha must remain routable`); + assert.equal(agent._captchaGateBlockResult(88, 'get_accessibility_tree'), null, `${label}: read-only detection must remain available`); + assert.equal(agent._captchaGateBlockResult(88, 'done')?.solveCaptchaRequired, true, `${label}: success completion escaped solve routing`); + assert.equal( + agent._captchaGateBlockResult(88, 'fetch_url', { method: 'POST' })?.solveCaptchaRequired, + true, + `${label}: network resubmission escaped solve routing`, + ); + assert.equal( + agent._captchaGateBlockResult(88, 'fetch_url', { method: 'GET' }), + null, + `${label}: read-only network request was over-blocked`, + ); + const active = agent._captchaGateStates.get(88); + const verificationGate = { + ...active, + status: 'verification_pending', + publicGate: { ...active.publicGate, status: 'verification_pending', solveAttempted: true }, + }; + agent._captchaGateStates.set(88, verificationGate); + for (const toolName of ['solve_captcha', 'click_ax', 'done_json']) { + assert.equal( + agent._captchaGateBlockResult(88, toolName)?.captchaVerificationRequired, + true, + `${label}: ${toolName} escaped post-solve verification`, + ); + } + agent._captchaGateStates.set(88, { + ...active, + status: 'manual_required', + publicGate: { ...active.publicGate, status: 'manual_required' }, + }); + assert.equal( + agent._captchaGateBlockResult(88, 'solve_captcha')?.manualCompletionRequired, + true, + `${label}: failed/unsupported challenge allowed another paid solve`, + ); + assert.equal(agent._captchaGateBlockResult(88, 'done'), null, `${label}: manual gate blocked partial completion`); + for (const toolName of ['navigate', 'new_tab', 'go_back', 'go_forward']) { + assert.equal(agent._captchaGateBlockResult(88, toolName), null, `${label}: manual gate blocked abandonment via ${toolName}`); + } + assert.equal( + agent._captchaGateBlockResult(88, 'click_ax')?.manualCompletionRequired, + true, + `${label}: abandonment exemption allowed an in-document mutation`, + ); + const sameDocumentResult = {}; + assert.equal( + agent._clearCaptchaGateAfterNavigation( + 88, + 'navigate', + 'https://example.test/signup?step=1', + 'https://example.test/signup?step=2', + sameDocumentResult, + ), + null, + `${label}: query-only navigation bypassed the challenged document gate`, + ); + assert.equal(agent._captchaGateStates.has(88), true, `${label}: same-document navigation deleted the gate`); + const abandonmentResult = {}; + const abandoned = agent._clearCaptchaGateAfterNavigation( + 88, + 'navigate', + 'https://example.test/signup', + 'https://other.test/home', + abandonmentResult, + ); + assert.equal(abandoned?.status, 'cleared', `${label}: leaving the challenged document did not clear the gate`); + assert.equal(abandoned?.clearedByNavigation, true, `${label}: navigation clearance reason missing`); + assert.equal(abandonmentResult.captchaGate?.clearedByNavigation, true, `${label}: navigation result omitted gate clearance`); + assert.equal(agent._captchaGateStates.has(88), false, `${label}: abandoned document gate remained persisted`); + } +}); + +test('one CAPTCHA solve remains gated until a root read confirms clearance', async () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const tabId = label === 'chrome' ? 8821 : 8822; + const agent = new AgentClass({ getVisionProvider: async () => null }); + const executed = []; + agent._persist = () => {}; + agent._ensureGateSetting = async () => {}; + agent._skipPermissionGate = true; + agent.executeTool = async (_tabId, name) => { + executed.push(name); + return { success: true, dispatched: true, injected: true }; + }; + agent._captchaGateStates.set(tabId, { + key: 'https://example.test/signup\nsecurity verification', + status: 'solve_required', + publicGate: { + status: 'solve_required', + challengeDialog: { label: 'Security verification' }, + diagnostics: { vendors: ['recaptcha'], frames: [] }, + }, + }); + + const firstMessages = []; + const first = await agent._executeToolBatch( + tabId, + [{ id: `${label}_solve_once`, function: { name: 'solve_captcha', arguments: '{}' } }], + firstMessages, + () => {}, + { supportsVision: false }, + '', + new Set(['solve_captcha']), + 1, + ); + assert.deepEqual(first, { action: 'continue' }, `${label}: solve should require a verification turn`); + assert.deepEqual(executed, ['solve_captcha'], `${label}: first solve did not dispatch exactly once`); + assert.equal(agent._captchaGateStates.get(tabId)?.status, 'verification_pending', `${label}: gate cleared before verification`); + + agent._lastAxScopes.set(tabId, { + documentToken: 'before-solve-document', + pageUrl: 'https://example.test/signup', + }); + agent._rememberAxScope(tabId, 'after-solve-document', 'https://example.test/signup'); + assert.equal( + agent._captchaGateStates.get(tabId)?.status, + 'verification_pending', + `${label}: same-URL document replacement cleared the pending solve gate`, + ); + + const retryMessages = []; + const retry = await agent._executeToolBatch( + tabId, + [{ id: `${label}_solve_retry`, function: { name: 'solve_captcha', arguments: '{}' } }], + retryMessages, + () => {}, + { supportsVision: false }, + '', + new Set(['solve_captcha']), + 2, + ); + assert.deepEqual(retry, { action: 'continue' }, `${label}: repeat solve block should request a root-read turn`); + assert.deepEqual(executed, ['solve_captcha'], `${label}: second paid solve dispatched`); + assert.match(String(retryMessages[0]?.content), /do not submit, dismiss, or call solve_captcha again/i); + + const changedChallengeResult = { + pageContent: 'dialog "Verification failed" [ref_900]\n button "Dismiss" [ref_902]', + pageUrl: 'https://example.test/signup?verification=failed', + }; + const firstPersistent = await agent._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + changedChallengeResult, + { filter: 'visible' }, + ); + assert.equal(firstPersistent.gate?.status, 'verification_pending', `${label}: changed challenge key reset the attempted solve`); + assert.equal(firstPersistent.gate?.verificationRetryRequired, true, `${label}: bounded verification retry was not requested`); + assert.equal(agent._captchaGateStates.get(tabId)?.key, 'https://example.test/signup\nsecurity verification', `${label}: changed challenge key replaced the attempted-solve identity`); + const persistent = await agent._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + changedChallengeResult, + { filter: 'visible' }, + ); + assert.equal(persistent.gate?.status, 'manual_required', `${label}: dialog surviving the bounded retry offered another solve`); + assert.equal(persistent.gate?.solveFailedToClearChallenge, true, `${label}: persistent-dialog reason missing`); + const changedManual = await agent._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + { + pageContent: 'dialog "Identity confirmation" [ref_910]\n button "Continue" [ref_911]', + pageUrl: 'https://example.test/identity-check', + }, + { filter: 'visible' }, + ); + assert.equal(changedManual.gate?.status, 'cleared', `${label}: unrelated post-CAPTCHA dialog kept the failed solve gated`); + assert.equal(agent._captchaGateStates.has(tabId), false, `${label}: unrelated post-CAPTCHA dialog left a persisted gate`); + + const confirmationAgent = new AgentClass({}); + confirmationAgent._captchaGateStates.set(tabId, { + key: 'https://example.test/signup\nsecurity verification', + status: 'verification_pending', + verificationAttempts: 0, + publicGate: { + status: 'verification_pending', + solveAttempted: true, + challengeDialog: { label: 'Security verification' }, + diagnostics: { vendors: ['recaptcha'], frames: [] }, + }, + }); + const confirmation = await confirmationAgent._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + { + pageContent: 'dialog "Signup complete" [ref_920]\n button "Continue" [ref_921]', + pageUrl: 'https://example.test/signup', + pageGate: { surface: 'dialog', label: 'Signup complete' }, + }, + { filter: 'visible' }, + ); + assert.equal(confirmation.gate?.status, 'cleared', `${label}: non-challenge confirmation dialog kept the solved CAPTCHA gated`); + assert.equal(confirmationAgent._captchaGateStates.has(tabId), false, `${label}: confirmation dialog leaked a cleared CAPTCHA gate`); + + const clearedAgent = new AgentClass({}); + clearedAgent._captchaGateStates.set(tabId, { + key: 'https://example.test/signup\nsecurity verification', + status: 'verification_pending', + publicGate: { + status: 'verification_pending', + solveAttempted: true, + diagnostics: { vendors: ['recaptcha'], frames: [] }, + }, + }); + const clearedResult = { + pageContent: 'main [ref_1]\n heading "Welcome" [ref_2]', + pageUrl: 'https://example.test/signup', + }; + const cleared = await clearedAgent._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + clearedResult, + {}, + ); + assert.equal(cleared.gate?.status, 'cleared', `${label}: absent root dialog did not clear gate`); + assert.equal(clearedAgent._captchaGateStates.has(tabId), false, `${label}: verified gate state leaked`); + } +}); + +test('CAPTCHA gate survives user continuations and only a complete dialog-capable root read clears it', async () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const tabId = label === 'chrome' ? 8823 : 8824; + const agent = new AgentClass({}); + const key = 'https://example.test/signup\nsecurity verification'; + const state = { + key, + status: 'manual_required', + publicGate: { + status: 'manual_required', + challengeDialog: { label: 'Security verification' }, + diagnostics: { vendors: [], frames: [] }, + }, + }; + agent._captchaGateStates.set(tabId, state); + agent.conversations.set(tabId, [{ role: 'system', content: 'test' }]); + + agent._clearRunLoopState(tabId); + assert.equal(agent._captchaGateStates.get(tabId)?.status, 'manual_required', `${label}: run continuation discarded the unresolved gate`); + assert.deepEqual(agent._conversationStorageEntry(tabId)?.captchaGateState, state, `${label}: unresolved gate was not persisted for worker restart`); + + for (const [toolArgs, extraResult, description] of [ + [{ filter: 'interactive' }, {}, 'interactive-only read'], + [{ filter: 'visible' }, { truncated: true }, 'truncated read'], + [{ filter: 'visible', ref_id: 'ref_20' }, {}, 'subtree read'], + [{ filter: 'visible', page: 0 }, {}, 'invalid zero page read'], + [{ filter: 'visible', page: 2 }, {}, 'later page read'], + [{ filter: 'visible', maxDepth: 8 }, {}, 'depth-limited read'], + [{ filter: 'visible' }, { pageGate: { surface: 'dialog', label: 'Security verification' } }, 'dialog-scoped read'], + ]) { + const observation = await agent._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + { + pageContent: 'main [ref_1]\n heading "Welcome" [ref_2]', + pageUrl: 'https://example.test/signup', + ...extraResult, + }, + toolArgs, + ); + assert.equal(observation.gate?.status, 'manual_required', `${label}: ${description} lost strong manual routing`); + assert.equal(agent._captchaGateStates.get(tabId)?.status, 'manual_required', `${label}: ${description} cleared the unresolved gate`); + } + + const cleared = await agent._observeCaptchaChallenge( + tabId, + 'get_accessibility_tree', + { + pageContent: 'main [ref_1]\n heading "Welcome" [ref_2]', + pageUrl: 'https://example.test/signup', + }, + { filter: 'visible' }, + ); + assert.equal(cleared.gate?.status, 'cleared', `${label}: complete visible root read did not clear the gate`); + assert.equal(agent._captchaGateStates.has(tabId), false, `${label}: cleared gate remained active`); + + agent._captchaGateStates.set(tabId, state); + agent._cleanupTab(tabId); + assert.equal(agent._captchaGateStates.has(tabId), false, `${label}: actual tab cleanup leaked gate state`); + } +}); + +test('unresolved CAPTCHA gates hydrate after a background worker restart', async () => { + for (const [label, AgentClass, apiName] of [ + ['chrome', AgentCh, 'chrome'], + ['firefox', AgentFx, 'browser'], + ]) { + const tabId = label === 'chrome' ? 8825 : 8826; + const agent = new AgentClass({}); + const key = agent._convKey(tabId); + const captchaGateState = { + key: 'https://example.test/signup\nsecurity verification', + status: 'verification_pending', + verificationAttempts: 1, + publicGate: { + status: 'verification_pending', + challengeDialog: { label: 'Security verification' }, + diagnostics: { vendors: ['recaptcha'], frames: [] }, + }, + }; + const previousApi = globalThis[apiName]; + globalThis[apiName] = { + ...(previousApi || {}), + storage: { + ...(previousApi?.storage || {}), + session: { + get: async () => ({ + [key]: { + messages: [{ role: 'system', content: 'test' }], + mode: 'act', + captchaGateState, + }, + }), + }, + }, + }; + try { + await agent._hydrate(tabId); + assert.deepEqual(agent._captchaGateStates.get(tabId), captchaGateState, `${label}: worker restart lost the unresolved CAPTCHA gate`); + } finally { + globalThis[apiName] = previousApi; + } + } +}); + +test('active CAPTCHA gate rejects the exact Dismiss, Close, Continue sequence before browser dispatch', async () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const agent = new AgentClass({ getVisionProvider: async () => null }); + const tabId = label === 'chrome' ? 8801 : 8802; + const executed = []; + const messages = []; + agent._persist = () => {}; + agent.executeTool = async (_tabId, name) => { + executed.push(name); + return { success: true }; + }; + agent._captchaGateStates.set(tabId, { + key: 'https://example.test/signup\nsecurity verification', + status: 'solve_required', + publicGate: { status: 'solve_required', diagnostics: { frames: [] } }, + }); + const toolCalls = [ + { id: `${label}_dismiss`, function: { name: 'click_ax', arguments: '{"ref_id":"ref_50486214628935"}' } }, + { id: `${label}_close`, function: { name: 'click', arguments: '{"text":"Close"}' } }, + { id: `${label}_continue`, function: { name: 'click_ax', arguments: '{"ref_id":"ref_3"}' } }, + ]; + const result = await agent._executeToolBatch( + tabId, + toolCalls, + messages, + () => {}, + { supportsVision: false }, + '', + new Set(['click_ax', 'solve_captcha']), + 1, + ); + assert.deepEqual(result, { action: 'continue' }, `${label}: blocked mutation did not request a fresh solve turn`); + assert.deepEqual(executed, [], `${label}: CAPTCHA-gated click reached browser dispatch`); + assert.equal(messages.filter(message => message.role === 'tool').length, 3, `${label}: queued calls need structural results`); + assert.match(String(messages[0].content), /Call solve_captcha once before any page-changing action/i, `${label}: strong solve routing missing`); + assert.match(String(messages[1].content), /active CAPTCHA gate requires a fresh routing turn/i, `${label}: queued Close was not skipped`); + assert.match(String(messages[2].content), /active CAPTCHA gate requires a fresh routing turn/i, `${label}: queued Continue was not skipped`); + } +}); + +test('mutation batch invokes CAPTCHA preflight before dispatch when no gate exists yet', async () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + for (const [toolName, toolArguments, allowedTools] of [ + ['click_ax', '{"ref_id":"ref_9"}', new Set(['click_ax', 'solve_captcha'])], + ['done', '{"success":true}', new Set(['done', 'solve_captcha'])], + ['fetch_url', '{"url":"https://example.test/signup","method":"POST"}', new Set(['fetch_url', 'solve_captcha'])], + ]) { + const agent = new AgentClass({ getVisionProvider: async () => null }); + const tabId = label === 'chrome' ? 8803 : 8804; + const executed = []; + let preflightCalls = 0; + agent.captchaSolverEnabled = true; + agent._persist = () => {}; + agent.executeTool = async (_tabId, name) => { + executed.push(name); + return { success: true }; + }; + agent._captchaMutationPreflight = async () => { + preflightCalls += 1; + const publicGate = { + status: 'solve_required', + challengeDialog: { label: 'Security verification' }, + diagnostics: { vendors: ['recaptcha'], frames: [] }, + }; + agent._captchaGateStates.set(tabId, { + key: 'https://example.test/signup\nsecurity verification', + status: 'solve_required', + publicGate, + }); + return publicGate; + }; + const messages = []; + const result = await agent._executeToolBatch( + tabId, + [{ id: `${label}_preflight_${toolName}`, function: { name: toolName, arguments: toolArguments } }], + messages, + () => {}, + { supportsVision: false }, + '', + allowedTools, + 1, + ); + assert.deepEqual(result, { action: 'continue' }, `${label}/${toolName}: preflight block did not request solve turn`); + assert.equal(preflightCalls, 1, `${label}/${toolName}: CAPTCHA preflight did not run exactly once`); + assert.deepEqual(executed, [], `${label}/${toolName}: action dispatched before preflight gate`); + assert.match(String(messages[0]?.content), /Call solve_captcha once now/i); + } + } +}); + test('loop detection classifies mutating tools from each build tool list, not a test copy', () => { const builds = [ ['chrome', AgentCh, MUTATION_TOOLS_CH, STATE_CHANGE_TOOLS_CH], @@ -8022,6 +8589,69 @@ test('cloud runs force trace capture without changing the interactive opt-in def assert.match(recorderSource, /forced:\s*await isForcedTraceRun\(runId\)/); }); +test('cloud trace keeps CAPTCHA frame/vendor diagnostics after the rolling update window drops the event', async () => { + const session = {}; + const tab = { id: 71, url: 'https://example.test/signup', active: true, windowId: 4 }; + const agent = { + isRunning: () => false, + abort: () => {}, + setApiMutationsAllowed: () => {}, + processMessage: async (_tabId, _task, onUpdate) => { + onUpdate('captcha_gate', { + status: 'manual_required', + diagnostics: { + vendors: ['arkose', 'recaptcha'], + frames: [ + { frameUrl: 'https://client-api.arkoselabs.com/fc/gc/', vendor: 'arkose' }, + ], + }, + }); + for (let index = 0; index < 205; index++) { + onUpdate('thinking', { content: `step ${index}` }); + } + onUpdate('run_status', { + status: 'captcha_manual_required', + message: 'Manual verification is required.', + }); + return 'Manual verification is required.'; + }, + }; + const controller = createCloudRunController({ + chromeApi: { + tabs: { + query: async () => [tab], + get: async () => tab, + update: async () => tab, + }, + windows: { update: async () => ({}) }, + storage: { + local: { get: async () => ({ webbrainCloudBridgeEnabled: false }) }, + session: { + get: async key => ({ [key]: session[key] || [] }), + set: async value => Object.assign(session, value), + }, + }, + runtime: { sendMessage: async () => ({ connected: false }) }, + }, + agent, + ensureOffscreen: async () => {}, + makeRunId: () => 'cloud_captcha_diagnostics', + }); + + const started = await controller.startRun({ task: 'Continue signup' }); + await new Promise(resolve => setTimeout(resolve, 0)); + const completed = await controller.status({ runId: started.runId }); + assert.equal(completed.status, 'failed', 'manual CAPTCHA requirement must not report cloud success'); + assert.equal(completed.updates.some(update => update.type === 'captcha_gate'), false, 'setup did not roll the original event out'); + assert.deepEqual(completed.captchaDiagnostics?.diagnostics?.vendors, ['arkose', 'recaptcha']); + assert.equal( + completed.captchaDiagnostics?.diagnostics?.frames?.[0]?.frameUrl, + 'https://client-api.arkoselabs.com/fc/gc/', + ); + const persisted = session.webbrainCloudRunSnapshots.find(row => row.runId === started.runId); + assert.deepEqual(persisted?.captchaDiagnostics?.diagnostics?.vendors, ['arkose', 'recaptcha']); +}); + test('cloud run controller fails interrupted runs after service-worker restart', async () => { const row = { runId: 'run_old', @@ -51757,6 +52387,8 @@ function captchaEl(tag, attrs = {}, children = []) { tagName: tag.toUpperCase(), children, src: attrs.src, + innerText: attrs.innerText || '', + textContent: attrs.textContent || attrs.innerText || '', hidden: attrs.hidden === true, style: {}, classList: { contains: (c) => String(attrs.class || '').split(/\s+/).includes(c) }, @@ -51766,6 +52398,11 @@ function captchaEl(tag, attrs = {}, children = []) { querySelector: (sel) => captchaMatchAll(children, sel)[0] || null, querySelectorAll: (sel) => captchaMatchAll(children, sel), }; + for (const child of children) child.parentElement = el; + el.contains = (candidate) => { + if (candidate === el) return true; + return children.some(child => child === candidate || child.contains?.(candidate)); + }; return el; } @@ -51802,7 +52439,7 @@ function captchaMatchAll(roots, selectorList) { return flat.filter(el => selectors.some(sel => matchesOne(el, sel))); } -async function detectCaptchaOnFakePage(build, nodes) { +async function withCaptchaFakePage(build, nodes, callback) { const document = { querySelector: (s) => captchaMatchAll(nodes, s)[0] || null, querySelectorAll: (s) => captchaMatchAll(nodes, s), @@ -51829,13 +52466,27 @@ async function detectCaptchaOnFakePage(build, nodes) { // The detector reaches for the extension API by name; give each build the // executeScript shape it expects and run the payload in-process. globalThis.chrome = { + webNavigation: { + getAllFrames: async () => [{ + frameId: 0, + parentFrameId: -1, + url: location.href, + }], + }, scripting: { - executeScript: async ({ func }) => [{ frameId: 0, result: func() }], + executeScript: async ({ func, args = [] }) => [{ + frameId: 0, + result: func(...args), + }], }, }; globalThis.browser = { webNavigation: { - getAllFrames: async () => [{ frameId: 0, url: location.href }], + getAllFrames: async () => [{ + frameId: 0, + parentFrameId: -1, + url: location.href, + }], }, tabs: { executeScript: async (_tabId, { code }) => [vm.runInNewContext(code, { @@ -51850,8 +52501,7 @@ async function detectCaptchaOnFakePage(build, nodes) { }, }; try { - const mod = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/agent/captcha-solver.js`)).href); - return (await mod.detectCaptcha(1)).selected; + return await callback(); } finally { globalThis.document = previous.document; globalThis.chrome = previous.chrome; @@ -51863,6 +52513,475 @@ async function detectCaptchaOnFakePage(build, nodes) { } } +async function detectCaptchaOnFakePage(build, nodes) { + return withCaptchaFakePage(build, nodes, async () => { + const mod = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/agent/captcha-solver.js`)).href); + return (await mod.detectCaptcha(1)).selected; + }); +} + +test('challenge-dialog routing detects supported widgets and diagnoses unsupported Arkose frames', async () => { + for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const supportedNodes = [ + captchaEl('div', { role: 'dialog', innerText: 'Security verification' }, [ + captchaEl('h2', { textContent: 'Security verification' }), + captchaEl('div', { class: 'g-recaptcha', 'data-sitekey': 'SUPPORTED_KEY' }), + captchaEl('iframe', { + src: 'https://www.google.com/recaptcha/api2/anchor?k=SUPPORTED_KEY&secret=must-not-persist', + }), + ]), + captchaEl('iframe', { + src: 'https://client-api.arkoselabs.com/fc/gc/?token=hidden-background-widget', + hidden: true, + }), + ]; + await withCaptchaFakePage(build, supportedNodes, async () => { + const agent = new AgentClass({}); + agent.captchaSolverEnabled = true; + agent._currentUrl = async () => 'https://example.test/signup'; + const result = { + pageContent: 'dialog "Security verification" [ref_100]\n button "Dismiss" [ref_101]\nbutton "Continue" [ref_3]', + }; + const observed = await agent._observeCaptchaChallenge(1, 'get_accessibility_tree', result); + assert.equal(observed.gate?.status, 'solve_required', `${build}: supported challenge was not routed to solve`); + assert.equal(observed.gate?.selectedType, 'recaptcha_v2', `${build}: selected type missing`); + assert.equal(observed.gate?.diagnostics?.vendors?.includes('recaptcha'), true, `${build}: reCAPTCHA vendor diagnostic missing`); + assert.equal(observed.gate?.diagnostics?.vendors?.includes('arkose'), true, `${build}: hidden vendor diagnostic missing`); + assert.equal(observed.gate?.unsupportedVendors, undefined, `${build}: hidden unrelated Arkose frame blocked supported reCAPTCHA`); + assert.equal( + observed.gate?.diagnostics?.frames?.some(frame => frame.frameUrl.includes('?')), + false, + `${build}: diagnostic frame URL retained its query string`, + ); + assert.equal(agent._captchaGateBlockResult(1, 'click')?.solveCaptchaRequired, true, `${build}: Dismiss/Continue click was not blocked`); + + const disabledAgent = new AgentClass({}); + disabledAgent.captchaSolverEnabled = false; + disabledAgent._currentUrl = async () => 'https://example.test/signup'; + const disabledObservation = await disabledAgent._observeCaptchaChallenge( + 2, + 'get_accessibility_tree', + { pageContent: result.pageContent }, + { filter: 'visible' }, + ); + assert.equal(disabledObservation.gate?.status, 'manual_required', `${build}: disabled solver did not fail closed`); + assert.equal(disabledObservation.gate?.solverDisabled, true, `${build}: disabled-solver reason missing`); + disabledAgent.captchaSolverEnabled = true; + const enabledGate = await disabledAgent._captchaMutationPreflight(2, 'click_ax'); + assert.equal(enabledGate?.status, 'solve_required', `${build}: enabling the solver did not re-evaluate the manual gate before mutation`); + + const transientAgent = new AgentClass({}); + transientAgent.captchaSolverEnabled = true; + transientAgent._currentUrl = async () => 'https://example.test/signup'; + transientAgent._captchaGateStates.set(3, { + key: 'https://example.test/signup\nsecurity verification', + status: 'manual_required', + publicGate: { + status: 'manual_required', + detectionFailed: true, + challengeDialog: { label: 'Security verification' }, + diagnostics: { vendors: [], frames: [] }, + }, + }); + const recoveredDetection = await transientAgent._observeCaptchaChallenge( + 3, + 'get_accessibility_tree', + { pageContent: result.pageContent }, + { filter: 'visible' }, + ); + assert.equal(recoveredDetection.gate?.status, 'solve_required', `${build}: transient detection failure was permanently sticky`); + + transientAgent._captchaGateStates.set(4, { + key: 'https://example.test/signup\nsecurity verification', + status: 'manual_required', + publicGate: { + status: 'manual_required', + detectionFailed: true, + solveFailed: true, + challengeDialog: { label: 'Security verification' }, + diagnostics: { vendors: [], frames: [] }, + }, + }); + const stickyPostSolve = await transientAgent._observeCaptchaChallenge( + 4, + 'get_accessibility_tree', + { pageContent: result.pageContent }, + { filter: 'visible' }, + ); + assert.equal(stickyPostSolve.gate?.status, 'manual_required', `${build}: post-solve failure became retryable`); + }); + + const invisibleV3Nodes = [ + captchaEl('script', { + src: 'https://www.google.com/recaptcha/enterprise.js?render=DIALOG_V3_KEY&action=signup', + }), + captchaEl('div', { + role: 'dialog', + innerText: 'Verify that you\u2019re a human', + }, [ + captchaEl('h2', { textContent: 'Verify that you\u2019re a human' }), + captchaEl('div', { + class: 'g-recaptcha g-recaptcha-v3', + 'data-sitekey': 'DIALOG_V3_KEY', + 'data-size': 'invisible', + 'data-action': 'signup', + 'data-enterprise': 'true', + }, [ + captchaEl('textarea', { name: 'g-recaptcha-response', id: 'dialog-v3-response' }), + ]), + ]), + ]; + await withCaptchaFakePage(build, invisibleV3Nodes, async () => { + const agent = new AgentClass({}); + agent.captchaSolverEnabled = true; + agent._currentUrl = async () => 'https://example.test/signup'; + const observed = await agent._observeCaptchaChallenge(1, 'get_accessibility_tree', { + pageContent: 'dialog "Verify that you\u2019re a human" [ref_150]\n button "Dismiss" [ref_151]', + }); + assert.equal(observed.gate?.status, 'solve_required', `${build}: invisible v3 widget inside the active dialog was not routable`); + assert.equal(observed.gate?.selectedType, 'recaptcha_v3_enterprise', `${build}: dialog-associated v3 type was lost`); + }); + + const unrelatedV3Nodes = [ + captchaEl('script', { + src: 'https://www.google.com/recaptcha/enterprise.js?render=BACKGROUND_ONLY&action=analytics', + }), + captchaEl('div', { + role: 'dialog', + innerText: 'Security verification\nUse your passkey', + }, [ + captchaEl('h2', { textContent: 'Security verification' }), + ]), + ]; + await withCaptchaFakePage(build, unrelatedV3Nodes, async () => { + const agent = new AgentClass({}); + agent.captchaSolverEnabled = true; + agent._currentUrl = async () => 'https://example.test/signup'; + const observed = await agent._observeCaptchaChallenge(1, 'get_accessibility_tree', { + pageContent: 'dialog "Security verification" [ref_170]\n heading "Use your passkey" [ref_171]', + }); + assert.equal(observed.gate?.status, 'manual_required', `${build}: unrelated global v3 loader was selected for a passkey dialog`); + assert.equal(observed.gate?.candidateNotCorrelated, true, `${build}: missing candidate/dialog correlation diagnostic`); + }); + + const unrelatedVisibleNodes = [ + captchaEl('div', { class: 'g-recaptcha', 'data-sitekey': 'VISIBLE_BACKGROUND' }), + captchaEl('div', { + role: 'dialog', + innerText: 'Security verification\nUse your passkey', + }, [ + captchaEl('h2', { textContent: 'Security verification' }), + ]), + ]; + await withCaptchaFakePage(build, unrelatedVisibleNodes, async () => { + const agent = new AgentClass({}); + agent.captchaSolverEnabled = true; + agent._currentUrl = async () => 'https://example.test/signup'; + const observed = await agent._observeCaptchaChallenge(1, 'get_accessibility_tree', { + pageContent: 'dialog "Security verification" [ref_180]\n heading "Use your passkey" [ref_181]', + }); + assert.equal(observed.gate?.status, 'manual_required', `${build}: unrelated visible reCAPTCHA was selected for a passkey dialog`); + assert.equal(observed.gate?.candidateNotCorrelated, true, `${build}: visible-only candidate did not fail closed`); + }); + + const arkoseNodes = [ + // A background reCAPTCHA signal must not be mistaken for the active + // Arkose dialog merely because it is the only supported candidate. + captchaEl('script', { + src: 'https://www.google.com/recaptcha/api.js?render=BACKGROUND_KEY&action=signup', + }), + captchaEl('iframe', { + src: 'https://client-api.arkoselabs.com/fc/gc/?token=sensitive-token', + }), + ]; + await withCaptchaFakePage(build, arkoseNodes, async () => { + const agent = new AgentClass({}); + agent.captchaSolverEnabled = true; + agent._currentUrl = async () => 'https://example.test/signup'; + const result = { + pageContent: 'dialog "Security verification" [ref_200]\n button "Dismiss" [ref_201]\nbutton "Continue" [ref_3]', + }; + const observed = await agent._observeCaptchaChallenge(1, 'get_accessibility_tree', result); + assert.equal(observed.gate?.status, 'manual_required', `${build}: unsupported Arkose challenge should stop for manual completion`); + assert.equal(observed.gate?.diagnostics?.vendors?.includes('arkose'), true, `${build}: Arkose vendor diagnostic missing`); + assert.equal(observed.gate?.diagnostics?.vendors?.includes('recaptcha'), true, `${build}: background reCAPTCHA diagnostic missing`); + assert.equal(observed.gate?.unsupportedVendors?.includes('arkose'), true, `${build}: active unsupported vendor was not fail-closed`); + const arkoseFrame = observed.gate?.diagnostics?.frames?.find(frame => frame.vendor === 'arkose'); + assert.equal(arkoseFrame?.frameUrl, 'https://client-api.arkoselabs.com/fc/gc/', `${build}: Arkose URL was not sanitized`); + }); + } +}); + +test('enabled CAPTCHA gate performs a read-only dialog preflight before the first mutation', async () => { + for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const challengeLabel = 'Complete "Security verification" now'; + const nodes = [ + captchaEl('div', { + role: 'dialog', + innerText: `${challengeLabel}\nDismiss`, + }, [ + captchaEl('h2', { textContent: challengeLabel }), + captchaEl('div', { class: 'g-recaptcha', 'data-sitekey': 'PREFLIGHT_KEY' }), + captchaEl('iframe', { + src: 'https://www.google.com/recaptcha/api2/anchor?k=PREFLIGHT_KEY', + }), + ]), + ]; + await withCaptchaFakePage(build, nodes, async () => { + const agent = new AgentClass({}); + agent.captchaSolverEnabled = true; + agent._currentUrl = async () => 'https://example.test/signup'; + const gate = await agent._captchaMutationPreflight(1, 'click_ax'); + assert.equal(gate?.status, 'solve_required', `${build}: first mutation bypassed read-only dialog detection`); + assert.equal(agent._captchaGateStates.get(1)?.status, 'solve_required', `${build}: preflight did not activate runtime gate`); + assert.equal(agent._captchaGateStates.get(1)?.challengeFrameId, 0, `${build}: preflight discarded the challenge frame identity`); + assert.equal(agent._captchaGateBlockResult(1, 'click_ax')?.solveCaptchaRequired, true, `${build}: detected Dismiss click was not blocked`); + + const disabledAgent = new AgentClass({}); + disabledAgent.captchaSolverEnabled = false; + disabledAgent._currentUrl = async () => 'https://example.test/signup'; + const disabledGate = await disabledAgent._captchaMutationPreflight(2, 'click_ax'); + assert.equal(disabledGate?.status, 'manual_required', `${build}: disabled solver skipped challenge preflight`); + assert.equal(disabledGate?.solverDisabled, true, `${build}: disabled preflight did not explain manual routing`); + assert.equal(disabledAgent._captchaGateStates.get(2)?.challengeFrameId, 0, `${build}: disabled preflight discarded the challenge frame identity`); + assert.equal(disabledAgent._captchaGateBlockResult(2, 'click_ax')?.manualCompletionRequired, true, `${build}: disabled preflight allowed the challenge mutation`); + + const abandonmentAgent = new AgentClass({}); + abandonmentAgent.captchaSolverEnabled = false; + const abandonmentGate = await abandonmentAgent._captchaMutationPreflight( + 3, + 'navigate', + { url: 'https://other.test/home' }, + ); + assert.equal(abandonmentGate, null, `${build}: navigation away armed a CAPTCHA gate`); + assert.equal(abandonmentAgent._captchaGateStates.has(3), false, `${build}: navigation preflight persisted a challenge gate`); + }); + } +}); + +test('challenge-dialog preflight honors ancestor iframe visibility across extension frames', async () => { + for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const previousChrome = globalThis.chrome; + const previousBrowser = globalThis.browser; + const navigationFrames = [ + { + frameId: 0, + parentFrameId: -1, + url: 'https://example.test/signup', + }, + { + frameId: 7, + parentFrameId: 0, + url: 'https://challenge.example.test/verify', + }, + ]; + let embeddingVisible = false; + let childChallengeVisible = true; + let childInspectionFails = false; + const payloadForFrame = frameId => frameId === 7 + ? { + challenge: childChallengeVisible + ? { label: 'Security verification' } + : null, + frameContext: { + frameUrl: 'https://challenge.example.test/verify', + frameName: 'verification-frame', + childFrames: [], + }, + } + : { + challenge: null, + frameContext: { + frameUrl: 'https://example.test/signup', + frameName: '', + childFrames: [{ + index: 0, + url: 'https://challenge.example.test/verify', + loadedUrl: '', + name: 'verification-frame', + visible: embeddingVisible, + }], + }, + }; + try { + if (build === 'chrome') { + globalThis.chrome = { + webNavigation: { + getAllFrames: async () => navigationFrames, + }, + scripting: { + executeScript: async ({ target }) => { + const frameIds = (target.allFrames ? [0, 7] : [0]) + .filter(frameId => frameId !== 7 || !childInspectionFails); + return frameIds.map(frameId => ({ + frameId, + result: payloadForFrame(frameId), + })); + }, + }, + }; + } else { + globalThis.browser = { + webNavigation: { + getAllFrames: async () => navigationFrames, + }, + tabs: { + executeScript: async (_tabId, details) => { + if (details.frameId === 7 && childInspectionFails) { + throw new Error('Cannot access challenge frame'); + } + return [payloadForFrame(details.frameId)]; + }, + }, + }; + } + const agent = new AgentClass({}); + const hidden = await agent._detectChallengeDialogBeforeMutation(1); + assert.equal(hidden, null, `${build}: dialog inside hidden ancestor iframe was treated as visible`); + embeddingVisible = true; + const visible = await agent._detectChallengeDialogBeforeMutation(1); + assert.equal(visible?.label, 'Security verification', `${build}: visible ancestor iframe suppressed challenge dialog`); + + agent._captchaGateStates.set(1, { + key: 'https://example.test/signup\nsecurity verification', + status: 'verification_pending', + verificationAttempts: 0, + challengeFrameId: 7, + challengeFrameUrl: 'https://challenge.example.test/verify', + publicGate: { + status: 'verification_pending', + solveAttempted: true, + challengeDialog: { label: 'Security verification' }, + diagnostics: { vendors: ['recaptcha'], frames: [] }, + }, + }); + const childStillVisible = await agent._observeCaptchaChallenge( + 1, + 'get_accessibility_tree', + { + pageContent: 'main [ref_10]\n heading "Signup" [ref_11]', + pageUrl: 'https://example.test/signup', + }, + { filter: 'visible' }, + ); + assert.equal(childStillVisible.gate?.status, 'verification_pending', `${build}: top-only root read cleared a visible child-frame challenge`); + assert.equal(agent._captchaGateStates.has(1), true, `${build}: child-frame gate state was deleted`); + + childInspectionFails = true; + const childUninspectable = await agent._observeCaptchaChallenge( + 1, + 'get_accessibility_tree', + { + pageContent: 'main [ref_15]\n heading "Signup" [ref_16]', + pageUrl: 'https://example.test/signup', + }, + { filter: 'visible' }, + ); + assert.equal(childUninspectable.gate?.status, 'verification_pending', `${build}: inaccessible challenge frame cleared the gate`); + assert.equal(childUninspectable.gate?.verificationFrameReadRequired, true, `${build}: inaccessible frame did not fail closed`); + + childInspectionFails = false; + childChallengeVisible = false; + const childCleared = await agent._observeCaptchaChallenge( + 1, + 'get_accessibility_tree', + { + pageContent: 'main [ref_20]\n heading "Welcome" [ref_21]', + pageUrl: 'https://example.test/signup', + }, + { filter: 'visible' }, + ); + assert.equal(childCleared.gate?.status, 'cleared', `${build}: cleared child frame kept CAPTCHA gate active`); + assert.equal(agent._captchaGateStates.has(1), false, `${build}: cleared child-frame gate remained persisted`); + } finally { + globalThis.chrome = previousChrome; + globalThis.browser = previousBrowser; + } + } +}); + +test('Chrome CAPTCHA detection preserves navigation-frame diagnostics when script inspection fails', async () => { + const previousChrome = globalThis.chrome; + globalThis.chrome = { + webNavigation: { + getAllFrames: async () => [ + { frameId: 0, parentFrameId: -1, url: 'https://example.test/signup' }, + { + frameId: 7, + parentFrameId: 0, + url: 'https://client-api.arkoselabs.com/fc/gc/?token=must-not-persist', + }, + ], + }, + scripting: { + executeScript: async () => { + throw new Error('Cannot access frame contents'); + }, + }, + }; + try { + const mod = await import(pathToFileURL(path.join(ROOT, 'src/chrome/src/agent/captcha-solver.js')).href); + await assert.rejects( + () => mod.detectCaptcha(7), + (error) => { + const arkose = error?.captchaDiagnostics?.frames?.find(frame => frame.vendor === 'arkose'); + assert.equal(arkose?.frameUrl, 'https://client-api.arkoselabs.com/fc/gc/'); + assert.equal(error?.captchaDiagnostics?.vendors?.includes('arkose'), true); + return true; + }, + ); + } finally { + globalThis.chrome = previousChrome; + } +}); + +test('challenge dialog with no enabled supported solver stops the batch for manual completion', async () => { + for (const [label, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const agent = new AgentClass({ getVisionProvider: async () => null }); + const tabId = label === 'chrome' ? 8811 : 8812; + const messages = []; + const updates = []; + agent.captchaSolverEnabled = false; + agent._skipPermissionGate = true; + agent._ensureGateSetting = async () => {}; + agent._currentUrl = async () => 'https://example.test/signup'; + agent._rememberMastodonObservation = async () => null; + agent._recordProgressObservation = async () => null; + agent._autoRecordProgressAction = () => null; + agent._persist = () => {}; + agent.executeTool = async () => ({ + success: true, + pageContent: 'dialog "Security verification" [ref_10]\n button "Dismiss" [ref_11]\nbutton "Continue" [ref_3]', + }); + const result = await agent._executeToolBatch( + tabId, + [{ id: `${label}_observe_challenge`, function: { name: 'get_accessibility_tree', arguments: '{}' } }], + messages, + (type, data) => updates.push({ type, data }), + { supportsVision: false }, + '', + new Set(['get_accessibility_tree']), + 1, + ); + assert.equal(result.action, 'return', `${label}: unsupported challenge did not stop`); + assert.equal(result.status, 'captcha_manual_required', `${label}: manual status missing`); + assert.match(result.value, /complete the verification manually/i, `${label}: manual request missing`); + assert.equal(updates.some(update => update.type === 'captcha_gate' && update.data?.status === 'manual_required'), true, `${label}: trace diagnostic update missing`); + assert.match(String(messages[0]?.content), /TRUSTED CAPTCHA GATE/, `${label}: model-facing hard gate note missing`); + } +}); + +test('CAPTCHA gate helper stays byte-identical and cloud traces retain gate diagnostics outside update rollover', () => { + assert.equal( + fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/captcha-gate.js'), 'utf8'), + fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/captcha-gate.js'), 'utf8'), + 'chrome and firefox CAPTCHA gate helpers must remain byte-identical', + ); + const cloudSource = fs.readFileSync(path.join(ROOT, 'src/chrome/src/cloud-runs.js'), 'utf8'); + assert.match(cloudSource, /if \(type === 'captcha_gate'\)[\s\S]*?run\.captchaDiagnostics = \{ \.\.\.scrubbedData, observedAt: run\.updatedAt \};/); + assert.match(cloudSource, /\.\.\.\(run\.captchaDiagnostics \? \{ captchaDiagnostics: run\.captchaDiagnostics \} : \{\}\)/); +}); + test('captcha detection: reCAPTCHA version, Enterprise edition and action stay in Chrome/Firefox parity', async () => { const cases = [ { @@ -52467,7 +53586,14 @@ test('Firefox detects and injects an inherited-origin srcdoc CAPTCHA through its iframe.contentDocument = childDocument; iframe.contentWindow = childWindow; - const topDocument = makeDocument([iframe]); + const verificationDialog = captchaEl('div', { + role: 'dialog', + innerText: 'Security verification', + }, [ + captchaEl('h2', { textContent: 'Security verification' }), + iframe, + ]); + const topDocument = makeDocument([verificationDialog]); const topWindow = { location: { href: 'https://example.test/form' }, name: '', @@ -52512,6 +53638,7 @@ test('Firefox detects and injects an inherited-origin srcdoc CAPTCHA through its assert.equal(detection.selected.websiteKey, 'KEY_SRCDOC', 'srcdoc site key was lost'); assert.equal(detection.selected.frameUrl, 'about:srcdoc', 'srcdoc frame URL was lost'); assert.equal(detection.selected.frameId, 0, 'srcdoc fallback did not retain its injectable parent frame'); + assert.equal(detection.selected.dialogAssociated, true, 'srcdoc CAPTCHA lost its active-dialog association'); assert.deepEqual( JSON.parse(JSON.stringify(detection.selected.framePath)), [{ index: 0, frameUrl: 'about:srcdoc', frameName: 'captcha-srcdoc' }], @@ -52615,6 +53742,13 @@ test('captcha frame visibility propagation demotes descendants of hidden embeddi 'KEY_HIDDEN_NESTED', `${build}: visible nested challenge was not restored`, ); + frameContexts[0].childFrames[0].dialogAssociated = true; + const dialogAdjusted = runtime.applyCaptchaFrameVisibility( + [visibleCandidate, nestedCandidate], + frameContexts, + navigationFrames, + ); + assert.equal(dialogAdjusted[1].dialogAssociated, true, `${build}: active-dialog iframe association did not reach the nested CAPTCHA`); const redirectedCandidate = { ...nestedCandidate,