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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 160 additions & 6 deletions src/chrome/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ import { solveCaptcha, detectCaptcha, injectToken, captchaParamError, captchaTyp
import { isCapsolverEnabled, normalizeCapsolverApiKey } from './capsolver-config.js';
import { captchaChallengeKey, captchaChallengeMatcherOptions, detectChallengeDialog, detectChallengeDialogInPage } from './captcha-gate.js';
import { applyCaptchaFrameVisibility } from './captcha-frame-runtime.js';
import {
cloudflareChallengeNavigationTransition,
cloudflareChallengePlatformTransition,
cloudflareChallengeResponseTransition,
cloudflareManagedChallengeGateState,
cloudflareManagedChallengeStorageKey,
normalizeCloudflareManagedChallengeState,
} from './cloudflare-managed-challenge.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 {
Expand Down Expand Up @@ -367,6 +375,7 @@ export class Agent extends LoopDetector {
this._runUpdateCallbacks = new Map();
this.plannerFollowUpSkipTabs = new Set(); // tabIds allowed one short follow-up after an approved try-mode plan
this.hydratedTabs = new Set(); // tabIds we've already pulled from storage
this._hydrationPromises = new Map(); // tabId -> shared in-flight storage hydration
this.persistTimers = new Map(); // tabId -> debounce handle
this.abortFlags = new Map(); // tabId -> boolean
this.currentRunId = new Map(); // tabId -> active trace runId (for recorder hooks)
Expand Down Expand Up @@ -487,6 +496,8 @@ export class Agent extends LoopDetector {
// at call time so rotating the key doesn't require a restart.
this.captchaSolverEnabled = false;
this._captchaGateStates = new Map(); // tabId -> { key, status, publicGate, challengeFrameId? }
this._cloudflareManagedChallenges = new Map(); // tabId -> sanitized response-backed interstitial state
this._cloudflareManagedChallengeTransitions = new Map(); // tabId -> serialized transition promise
// 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.
Expand Down Expand Up @@ -4066,6 +4077,88 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
return BROWSER_MUTATION_TOOLS.has(toolName);
}

async _persistCloudflareManagedChallenge(tabId, state) {
const key = cloudflareManagedChallengeStorageKey(tabId);
try {
if (state) {
await chrome.storage.session.set({ [key]: state });
} else {
await chrome.storage.session.remove(key);
}
} catch {}
}

async _applyCloudflareManagedChallengeTransition(tabId, transition) {
if (!Number.isInteger(tabId) || tabId < 0 || !transition?.changed) return null;
const state = normalizeCloudflareManagedChallengeState(transition.state);
if (state) {
this._cloudflareManagedChallenges.set(tabId, state);
const gate = cloudflareManagedChallengeGateState(state);
if (gate) this._captchaGateStates.set(tabId, gate);
await this._persistCloudflareManagedChallenge(tabId, state);
return { kind: transition.kind, gate: gate?.publicGate || null };
}
this._cloudflareManagedChallenges.delete(tabId);
if (this._captchaGateStates.get(tabId)?.cloudflareManagedChallenge === true) {
this._captchaGateStates.delete(tabId);
}
await this._persistCloudflareManagedChallenge(tabId, null);
return { kind: transition.kind, gate: null };
}

_queueCloudflareManagedChallengeTransition(tabId, createTransition) {
if (!Number.isInteger(tabId) || tabId < 0) return Promise.resolve(null);
const previous = this._cloudflareManagedChallengeTransitions.get(tabId)
|| Promise.resolve();
const transitionPromise = previous
.catch(() => null)
.then(async () => {
await this._hydrate(tabId);
return this._applyCloudflareManagedChallengeTransition(
tabId,
createTransition(this._cloudflareManagedChallenges.get(tabId)),
);
});
this._cloudflareManagedChallengeTransitions.set(tabId, transitionPromise);
return transitionPromise.finally(() => {
if (this._cloudflareManagedChallengeTransitions.get(tabId) === transitionPromise) {
this._cloudflareManagedChallengeTransitions.delete(tabId);
}
});
}

observeCloudflareManagedChallengeResponse(details) {
const tabId = details?.tabId;
return this._queueCloudflareManagedChallengeTransition(
tabId,
current => cloudflareChallengeResponseTransition(current, details),
);
}

observeCloudflareChallengePlatformRequest(details) {
const tabId = details?.tabId;
return this._queueCloudflareManagedChallengeTransition(
tabId,
current => cloudflareChallengePlatformTransition(current, details),
);
}

observeCloudflareManagedChallengeNavigation(details) {
const tabId = details?.tabId;
return this._queueCloudflareManagedChallengeTransition(
tabId,
current => cloudflareChallengeNavigationTransition(current, details),
);
}

_activeCloudflareManagedChallengeGate(tabId) {
const signal = this._cloudflareManagedChallenges.get(tabId);
const gate = cloudflareManagedChallengeGateState(signal);
if (!gate) return null;
this._captchaGateStates.set(tabId, gate);
return gate.publicGate;
}

_shouldRetryCaptchaManualGate(gate) {
const publicGate = gate?.publicGate;
const postSolveFailure = publicGate?.solveAttempted === true
Expand Down Expand Up @@ -4097,6 +4190,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
}
if (toolName === 'solve_captcha' && gate.status === 'solve_required') return null;
if (gate.status === 'manual_required') {
if (gate.publicGate?.cloudflareManagedChallenge === true) {
return {
success: false,
denied: true,
noDispatch: true,
captchaGate: true,
manualCompletionRequired: true,
cloudflareManagedChallenge: true,
captchaDiagnostics: gate.publicGate?.diagnostics || null,
error: 'A full-page Cloudflare managed challenge is active. Stop automation and ask the user to complete it manually. Do not submit or call solve_captcha; navigate only to abandon the challenged page. Read the page again after Cloudflare resumes the destination.',
};
}
return {
success: false,
denied: true,
Expand Down Expand Up @@ -4316,6 +4421,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
return { gate: null, loopCheck: { kind: 'none' } };
}

const cloudflareManagedGate = this._activeCloudflareManagedChallengeGate(tabId);
if (cloudflareManagedGate) {
toolResult.captchaGate = cloudflareManagedGate;
return { gate: cloudflareManagedGate, loopCheck: { kind: 'none' } };
}

const activeGate = this._captchaGateStates.get(tabId);
const treeFilter = String(toolArgs?.filter || 'all').toLowerCase();
let observedChallengeFrameId = Number.isInteger(toolResult.captchaChallengeFrameId)
Expand Down Expand Up @@ -5786,7 +5897,9 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
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 += captchaGateDecision.activeChallengeAfterSolve === true
resultContent += captchaGateDecision.cloudflareManagedChallenge === true
? '\n[TRUSTED CAPTCHA GATE: A response-backed full-page Cloudflare managed challenge is active. Stop automation and ask the user to complete it manually. Do not submit or call solve_captcha; the network/navigation monitor will clear the gate when Cloudflare resumes the destination.]'
: captchaGateDecision.activeChallengeAfterSolve === true
? '\n[TRUSTED CAPTCHA GATE: The active challenge frame is visible again after the one automatic solve. The site may have rejected the token. Do not call solve_captcha again; stop automation and ask the user to complete the challenge manually.]'
: '\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.' });
Expand Down Expand Up @@ -7849,11 +7962,38 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
*/
async _hydrate(tabId) {
if (this.hydratedTabs.has(tabId)) return;
this.hydratedTabs.add(tabId);
if (this.conversations.has(tabId)) return;
const inFlight = this._hydrationPromises.get(tabId);
if (inFlight) return inFlight;
const hydrationPromise = this._hydrateFromSession(tabId);
this._hydrationPromises.set(tabId, hydrationPromise);
try {
await hydrationPromise;
this.hydratedTabs.add(tabId);
} finally {
if (this._hydrationPromises.get(tabId) === hydrationPromise) {
this._hydrationPromises.delete(tabId);
}
}
}

async _hydrateFromSession(tabId) {
const conversationInMemory = this.conversations.has(tabId);
try {
const key = this._convKey(tabId);
const stored = await chrome.storage.session.get(key);
const cloudflareKey = cloudflareManagedChallengeStorageKey(tabId);
const [stored, cloudflareStored] = await Promise.all([
chrome.storage.session.get(key),
chrome.storage.session.get(cloudflareKey),
]);
const cloudflareSignal = normalizeCloudflareManagedChallengeState(
cloudflareStored?.[cloudflareKey],
);
if (cloudflareSignal) {
this._cloudflareManagedChallenges.set(tabId, cloudflareSignal);
const cloudflareGate = cloudflareManagedChallengeGateState(cloudflareSignal);
if (cloudflareGate) this._captchaGateStates.set(tabId, cloudflareGate);
}
if (conversationInMemory) return;
const entry = stored?.[key];
if (entry && Array.isArray(entry.messages) && entry.messages.length > 0) {
this.conversations.set(tabId, entry.messages);
Expand Down Expand Up @@ -7915,8 +8055,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
)
&& captchaGateState.publicGate
&& typeof captchaGateState.publicGate === 'object'
&& captchaGateState.cloudflareManagedChallenge !== true
&& captchaGateState.publicGate.cloudflareManagedChallenge !== true
) {
this._captchaGateStates.set(tabId, normalizeCaptchaGateState(captchaGateState));
if (!cloudflareSignal) {
this._captchaGateStates.set(tabId, normalizeCaptchaGateState(captchaGateState));
}
}
}
} catch (e) { /* session storage may be unavailable */ }
Expand All @@ -7941,6 +8085,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
const serialized = serializeConversationForSession(messages, {
maxBytes: options.maxBytes || SESSION_CONVERSATION_BUDGET_BYTES,
});
const captchaGateState = this._captchaGateStates.get(tabId) || null;
return {
mode: this.conversationModes.get(tabId) || 'ask',
messages: serialized.messages,
Expand All @@ -7953,7 +8098,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
selectionGroundingScope: this.selectionGroundingScopes.get(tabId) || null,
clarificationAuthorizationGuard: persistedClarificationGuard,
richTextToolbarAudit: this._persistedRichTextToolbarAudit(tabId),
captchaGateState: this._captchaGateStates.get(tabId) || null,
captchaGateState: captchaGateState?.cloudflareManagedChallenge === true
|| captchaGateState?.publicGate?.cloudflareManagedChallenge === true
? null
: captchaGateState,
};
}

Expand Down Expand Up @@ -11773,6 +11921,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
this.recentNavUrls.delete(tabId);
this.completionInvariants.delete(tabId);
this._captchaGateStates.delete(tabId);
if (preserveRunGuard) {
this._activeCloudflareManagedChallengeGate(tabId);
} else {
this._cloudflareManagedChallenges.delete(tabId);
this._persistCloudflareManagedChallenge(tabId, null);
}
this._userAttachmentHandles.delete(tabId);
this._runUpdateCallbacks.delete(tabId);
if (!preserveRunGuard) this.persistenceDegradedTabs.delete(tabId);
Expand Down
139 changes: 139 additions & 0 deletions src/chrome/src/agent/cloudflare-managed-challenge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Shared, browser-neutral state transitions for Cloudflare interstitial
// Challenge Pages. This file is mirrored in the Firefox tree; keep both
// copies byte-identical.

export const CLOUDFLARE_MITIGATED_HEADER = 'cf-mitigated';
export const CLOUDFLARE_MITIGATED_CHALLENGE = 'challenge';

export function cloudflareChallengeDocumentKey(value) {
try {
const parsed = new URL(String(value || ''));
if (!['http:', 'https:'].includes(parsed.protocol)) return '';
return `${parsed.origin}${parsed.pathname}`.slice(0, 1000);
} catch {
return '';
}
}

export function isCloudflareChallengePlatformUrl(value) {
try {
const parsed = new URL(String(value || ''));
return /^\/cdn-cgi\/challenge-platform(?:\/|$)/i.test(parsed.pathname);
} catch {
return false;
}
}

function responseHeaderValue(headers, name) {
const target = String(name || '').toLowerCase();
for (const header of Array.isArray(headers) ? headers : []) {
if (String(header?.name || '').toLowerCase() !== target) continue;
return String(header?.value || '').trim().toLowerCase();
}
return '';
}

export function cloudflareChallengeResponseTransition(current, details, now = Date.now()) {
if (details?.type !== 'main_frame' || !Number.isInteger(details?.tabId) || details.tabId < 0) {
return { state: current || null, changed: false, kind: 'ignored' };
}
const challenged = responseHeaderValue(
details.responseHeaders,
CLOUDFLARE_MITIGATED_HEADER,
) === CLOUDFLARE_MITIGATED_CHALLENGE;
if (!challenged) {
return current
? { state: null, changed: true, kind: 'cleared_by_response' }
: { state: null, changed: false, kind: 'unchanged' };
}
const documentKey = cloudflareChallengeDocumentKey(details.url);
if (!documentKey) return { state: current || null, changed: false, kind: 'ignored' };
return {
state: {
active: true,
documentKey,
detectedAt: current?.documentKey === documentKey
? Number(current.detectedAt) || now
: now,
lastResponseAt: now,
lastChallengePlatformActivityAt: current?.documentKey === documentKey
? Number(current.lastChallengePlatformActivityAt) || 0
: 0,
},
changed: true,
kind: current?.active ? 'retained_by_response' : 'armed_by_response',
};
}

export function cloudflareChallengePlatformTransition(current, details, now = Date.now()) {
if (
!current?.active
|| !Number.isInteger(details?.tabId)
|| details.tabId < 0
|| !isCloudflareChallengePlatformUrl(details.url)
) {
return { state: current || null, changed: false, kind: 'ignored' };
}
return {
state: {
...current,
lastChallengePlatformActivityAt: now,
},
changed: true,
kind: 'retained_by_platform_activity',
};
}

export function cloudflareChallengeNavigationTransition(current, details) {
if (!current?.active || details?.frameId !== 0) {
return { state: current || null, changed: false, kind: 'ignored' };
}
const documentKey = cloudflareChallengeDocumentKey(details.url);
if (!documentKey || documentKey === current.documentKey) {
return { state: current, changed: false, kind: 'unchanged' };
}
return { state: null, changed: true, kind: 'cleared_by_navigation' };
}

export function cloudflareManagedChallengeStorageKey(tabId) {
return `cloudflareManagedChallenge:${tabId}`;
}

export function normalizeCloudflareManagedChallengeState(value) {
if (!value?.active) return null;
const documentKey = cloudflareChallengeDocumentKey(value.documentKey);
if (!documentKey) return null;
return {
active: true,
documentKey,
detectedAt: Math.max(0, Number(value.detectedAt) || 0),
lastResponseAt: Math.max(0, Number(value.lastResponseAt) || 0),
lastChallengePlatformActivityAt: Math.max(
0,
Number(value.lastChallengePlatformActivityAt) || 0,
),
};
}

export function cloudflareManagedChallengeGateState(signal) {
const normalized = normalizeCloudflareManagedChallengeState(signal);
if (!normalized) return null;
const publicGate = {
status: 'manual_required',
cloudflareManagedChallenge: true,
challengeDialog: { label: 'Cloudflare managed challenge interstitial' },
diagnostics: {
vendors: ['cloudflare'],
frames: [],
responseHeaderSignal: true,
challengePlatformActivity: normalized.lastChallengePlatformActivityAt > 0,
},
};
return {
key: `${normalized.documentKey}\ncloudflare managed challenge interstitial`,
status: 'manual_required',
cloudflareManagedChallenge: true,
cloudflareSignal: normalized,
publicGate,
};
}
Loading
Loading