diff --git a/ci/test.mjs b/ci/test.mjs index 0baf64863..c4c61f3b3 100644 --- a/ci/test.mjs +++ b/ci/test.mjs @@ -50,8 +50,8 @@ assert.equal(googleFormsScenario.verify.scheduledJobs.definitions[1].afterSecond assert.equal(googleFormsScenario.session_settings.scheduledRequireConsequentialConfirmation, false); assert.match(googleFormsScenario.task, /after_seconds=0/); assert.match(googleFormsScenario.task, /after_seconds=60/); -assert.match(smokeWorkflow, /- "src\/chrome\/src\/background\.js"/); -assert.match(smokeWorkflow, /- "src\/chrome\/src\/offscreen\/cloud-bridge\.js"/); +assert.match(smokeWorkflow, /^\s{2}workflow_dispatch:\s*$/m); +assert.doesNotMatch(smokeWorkflow, /^\s{2}(?:push|schedule):\s*$/m); // A job killed by its own timeout skips every cleanup path and leaks the cloud // browser session it was driving, so each workflow's timeout must cover the // serial budget of the widest pack it can run. Deriving that here means adding diff --git a/package-lock.json b/package-lock.json index 95f4898fe..87764e12c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webbrain", - "version": "30.0.3", + "version": "30.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webbrain", - "version": "30.0.3", + "version": "30.0.4", "license": "MIT", "devDependencies": { "playwright": "^1.48.0", diff --git a/package.json b/package.json index 90c96245f..9c87b18f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webbrain", - "version": "30.0.3", + "version": "30.0.4", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "private": true, "type": "module", diff --git a/src/chrome/ARCHITECTURE.md b/src/chrome/ARCHITECTURE.md index 8d2dd385a..e08586149 100644 --- a/src/chrome/ARCHITECTURE.md +++ b/src/chrome/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Chrome/Edge Extension — Architecture -> Version 30.0.3 · Manifest V3 · Service Worker background +> Version 30.0.4 · Manifest V3 · Service Worker background ## High-Level Overview diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index 8293addea..05fab9395 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WebBrain", - "version": "30.0.3", + "version": "30.0.4", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "sidePanel", diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 834c44e80..1565e0f87 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -251,7 +251,7 @@ function captchaCandidateMatchesGate(candidate, identity) { const candidatePath = captchaCandidateFramePath(candidate); if ( expectedPath.length !== candidatePath.length - || expectedPath.some((value, index) => value !== candidatePath[index]) + || expectedPath.some((value, index) => !Object.is(value, candidatePath[index])) ) return false; const expectedFieldIds = [identity.responseFieldId, identity.alsoResponseFieldId] @@ -2430,6 +2430,13 @@ export class Agent extends LoopDetector { if (!RICH_TEXT_TOOLBAR_GUARDED_TOOLS.has(toolName)) return null; const probe = await this._probeRichTextToolbarRetryTarget(tabId, toolName, args); if (!probe?.resolved) { + const knownRefBlock = this._richTextToolbarGuard.blockRef( + tabId, + toolName, + args, + this._lastAxScopes.get(tabId)?.documentToken || '', + ); + if (knownRefBlock) return knownRefBlock; return DISPATCH_BINDING_TOOLS.has(toolName) ? { success: false, @@ -20000,7 +20007,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (name === 'iframe_click') { let dispatched = false; try { - const urlFilter = args.urlFilter || ''; + const urlFilter = String(args.urlFilter || '').trim(); const selector = args.selector; if (!selector) { return { @@ -20010,6 +20017,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'selector is required', }; } + if (!urlFilter) { + return { + success: false, + dispatched: false, + noDispatch: true, + error: 'urlFilter is required so the iframe target and permission scope remain stable', + }; + } const hasExplicitMatchIndex = args.matchIndex !== undefined && args.matchIndex !== null; const requestedMatchIndex = hasExplicitMatchIndex ? Number(args.matchIndex) : 0; if (!Number.isInteger(requestedMatchIndex) || requestedMatchIndex < 0) { @@ -20143,6 +20158,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let dispatched = false; try { const selector = args.selector; + const urlFilter = String(args.urlFilter || '').trim(); const text = args.text || ''; const clear = !!args.clear; if (!selector) { @@ -20153,6 +20169,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'selector is required', }; } + if (!urlFilter) { + return { + success: false, + dispatched: false, + noDispatch: true, + error: 'urlFilter is required so the iframe target and its verification scope remain stable', + }; + } let binding = dispatchBinding; let targetFrameId = binding?.frameId; // The preflight sweeps every frame; when it already reported that no @@ -20184,7 +20208,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d selector, text, clear, - urlFilter: args.urlFilter || '', + urlFilter, matchIndex: args.matchIndex, }); } diff --git a/src/chrome/src/agent/completion-invariant.js b/src/chrome/src/agent/completion-invariant.js index e19dfac18..fd4634341 100644 --- a/src/chrome/src/agent/completion-invariant.js +++ b/src/chrome/src/agent/completion-invariant.js @@ -93,14 +93,17 @@ function normalizedIframeMatchIndex(value) { function iframeFormObligation(args = {}, result = {}) { const selector = String(args?.selector || '').trim(); + const scope = normalizedIframeScope(args?.urlFilter); const finalValue = typeof result?.value === 'string' ? result.value : (typeof result?.frame?.value === 'string' ? result.frame.value : null); - if (!selector) return null; + // verify_form addresses iframe targets by urlFilter. Never create debt that + // the verification tool has no stable scope with which to discharge it. + if (!selector || !scope) return null; const matchMode = finalValue !== null || args?.clear === true ? 'exact' : 'suffix'; const rawExpectedValue = String(finalValue ?? args?.text ?? ''); return { - scope: normalizedIframeScope(args?.urlFilter), + scope, frameId: Number.isInteger(result?.frameId) ? result.frameId : null, selector, matchIndex: normalizedIframeMatchIndex(result?.matchIndex ?? args?.matchIndex), diff --git a/src/chrome/src/agent/loop-detector.js b/src/chrome/src/agent/loop-detector.js index 546662856..0fdb56b6c 100644 --- a/src/chrome/src/agent/loop-detector.js +++ b/src/chrome/src/agent/loop-detector.js @@ -313,7 +313,6 @@ export class LoopDetector { refId: typeof value?.ref_id === 'string' ? value.ref_id.trim() : '', }); const page = Number(args?.page || 1); - const hasRef = typeof args?.ref_id === 'string' && args.ref_id.trim() !== ''; const currentScopeKey = scopeKeyFor(args); const currentPageKey = `${currentScopeKey}|${page}`; const sequentialPage = previous.total > 0 @@ -321,10 +320,14 @@ export class LoopDetector { && page === previous.nextPage && currentScopeKey === previous.scopeKey && !previous.seenPages.has(currentPageKey); - const repeatedRootOrPage = previous.total > 0 && !sequentialPage; - const content = String(result?.pageContent || '').trim(); - const meaningfulLines = content ? content.split(/\r?\n/).filter(line => line.trim()).length : 0; - const suspicious = !sequentialPage && (hasRef || repeatedRootOrPage || (hasRef && meaningfulLines <= 1)); + const repeatedScopeOutOfSequence = previous.total > 0 + && currentScopeKey === previous.scopeKey + && !sequentialPage; + const repeatedExactPage = previous.seenPages.has(currentPageKey); + // A first read of a new ref-anchored subtree is legitimate drill-down + // progress. Only repeated/out-of-order reads of the same scope are loop + // evidence; the total-read cap below still bounds endless ref hopping. + const suspicious = !sequentialPage && (repeatedExactPage || repeatedScopeOutOfSequence); const state = { total: previous.total + 1, diff --git a/src/chrome/src/agent/mutation-tools.js b/src/chrome/src/agent/mutation-tools.js index 85e9d4d4c..89442c071 100644 --- a/src/chrome/src/agent/mutation-tools.js +++ b/src/chrome/src/agent/mutation-tools.js @@ -10,16 +10,15 @@ /** Tools that change page or browser state, gating auto-screenshots and * unknown-outcome normalization as well as loop detection. */ -export const STATE_CHANGE_TOOLS = new Set(['navigate', 'promote_iframe', 'new_tab', 'go_back', 'go_forward', 'click', 'click_ax', 'set_checked', 'type_text', 'type_ax', 'set_field', 'iframe_type', 'press_keys', 'scroll', 'hover', 'drag_drop', 'inject_css', 'remove_injected_css', 'patch_element', 'revert_patch', 'execute_js', 'inspect_event_listeners', 'highlight_element', 'execute_webmcp_tool']); +export const STATE_CHANGE_TOOLS = new Set(['navigate', 'promote_iframe', 'new_tab', 'go_back', 'go_forward', 'click', 'click_ax', 'set_checked', 'iframe_click', 'type_text', 'type_ax', 'set_field', 'iframe_type', 'press_keys', 'scroll', 'hover', 'drag_drop', 'inject_css', 'remove_injected_css', 'patch_element', 'revert_patch', 'execute_js', 'inspect_event_listeners', 'highlight_element', 'execute_webmcp_tool']); /** * Everything the failed-action loop counters treat as a browser mutation. - * Adds the remaining frame- and upload-scoped tools that act on the page but are not + * Adds the remaining upload- and challenge-scoped tools that act on the page but are not * part of the auto-screenshot state-change set. */ export const BROWSER_MUTATION_TOOLS = new Set([ ...STATE_CHANGE_TOOLS, - 'iframe_click', 'upload_file', 'solve_captcha', ]); diff --git a/src/chrome/src/agent/permission-gate.js b/src/chrome/src/agent/permission-gate.js index c1c81d94d..732becef2 100644 --- a/src/chrome/src/agent/permission-gate.js +++ b/src/chrome/src/agent/permission-gate.js @@ -104,6 +104,8 @@ export const UNTRUSTED_CONTENT_TOOLS = new Set([ 'download_resource_from_page', 'download_files', 'download_file', + // Upload results can echo page-controlled input labels and accept metadata. + 'upload_file', // hover returns the element's accessible name (aria-label/title/innerText). 'hover', // list_downloads returns each download's url + filename; the filename can diff --git a/src/chrome/src/agent/planner.js b/src/chrome/src/agent/planner.js index a4a576bfa..59546c9ee 100644 --- a/src/chrome/src/agent/planner.js +++ b/src/chrome/src/agent/planner.js @@ -527,9 +527,13 @@ export function normalizePlan(obj, opts = {}) { const normalizedScheduling = tool === 'schedule_task' || tool === 'schedule_resume' ? { tool, hint: sanitizeText(scheduling.hint, 300) } : null; - const risks = Array.isArray(obj.risks) - ? obj.risks.map((risk) => sanitizeText(risk, 200)).filter(Boolean).slice(0, 6) + const riskEntries = Array.isArray(obj.risks) + ? obj.risks + .map((risk, sourceIndex) => ({ sourceIndex, text: sanitizeText(risk, 200) })) + .filter(entry => entry.text) + .slice(0, 6) : []; + const risks = riskEntries.map(entry => entry.text); const localizedInput = obj.localized && typeof obj.localized === 'object' ? obj.localized : {}; const providedLocalizedSteps = Array.isArray(localizedInput.steps) ? localizedInput.steps.slice(0, 12).map((step, i) => ({ @@ -545,7 +549,7 @@ export function normalizePlan(obj, opts = {}) { || step.action, })); const providedLocalizedRisks = Array.isArray(localizedInput.risks) - ? localizedInput.risks.slice(0, 6).map((risk) => sanitizeText(risk, 200)) + ? localizedInput.risks : []; const requestedLocale = normalizePlannerLocale(opts.locale || localizedInput.locale); if (opts.requireIntent) { @@ -556,7 +560,9 @@ export function normalizePlan(obj, opts = {}) { locale: requestedLocale, summary: localizedSummary || summary, steps: localizedSteps, - risks: risks.map((risk, index) => providedLocalizedRisks[index] || risk), + risks: riskEntries.map(({ sourceIndex, text: risk }) => ( + sanitizeText(providedLocalizedRisks[sourceIndex], 200) || risk + )), }; const submissionBearingPlan = executablePlan || requestKind === 'clarify'; const requiresSubmission = submissionBearingPlan diff --git a/src/chrome/src/agent/rich-text-toolbar-probe.js b/src/chrome/src/agent/rich-text-toolbar-probe.js index d11f70205..86a1c8862 100644 --- a/src/chrome/src/agent/rich-text-toolbar-probe.js +++ b/src/chrome/src/agent/rich-text-toolbar-probe.js @@ -20,6 +20,15 @@ function withDispatchBinding(probe, frameId = probe?.frameId) { }; } +function messageOrigin(url) { + try { + const origin = new URL(String(url || '')).origin; + return origin && origin !== 'null' ? origin : ''; + } catch { + return ''; + } +} + export class RichTextToolbarProbe { constructor(agent) { this.agent = agent; @@ -70,19 +79,23 @@ export class RichTextToolbarProbe { child = parent; } if (!child || child.frameId !== 0) return null; + const opaqueFrameIds = new Set(); const exactChildRect = async edge => { const token = `wb-frame-${Date.now()}-${secureRandomBase36Token(12)}`; + const parentOriginOpaque = opaqueFrameIds.has(edge.parent.frameId); + const parentOrigin = parentOriginOpaque ? '' : messageOrigin(edge.parent.url); + const expectedChildOrigin = messageOrigin(edge.child.url); const parentResponse = chrome.tabs.sendMessage(tabId, { target: 'redaction-content', action: 'wait_for_exact_child_frame_rect', - params: { token, scrollIntoView: true }, + params: { token, expectedChildOrigin, allowOpaqueChildOrigin: parentOriginOpaque, scrollIntoView: true }, }, { frameId: edge.parent.frameId }).catch(() => null); await new Promise(resolve => setTimeout(resolve, 0)); try { await chrome.tabs.sendMessage(tabId, { target: 'redaction-content', action: 'announce_exact_child_frame', - params: { token }, + params: { token, parentOrigin }, }, { frameId: edge.child.frameId }); } catch {} return parentResponse; @@ -92,6 +105,7 @@ export class RichTextToolbarProbe { let frameOwnerMeta = null; for (const edge of edges) { const exact = await exactChildRect(edge); + if (exact?.childOriginOpaque === true) opaqueFrameIds.add(edge.child.frameId); const parentTransform = transforms.get(edge.parent.frameId); const childSnapshot = snapshotById.get(edge.child.frameId); const childWidth = Number(childSnapshot?.viewport?.width); @@ -160,8 +174,8 @@ export class RichTextToolbarProbe { } async legacyIframeTypeAllFrames(tabId, { selector, text, clear, urlFilter, matchIndex: requestedMatchIndex }) { - const matchIndex = Number.isInteger(Number(requestedMatchIndex)) && Number(requestedMatchIndex) >= 0 - ? Number(requestedMatchIndex) + const matchIndex = Number.isInteger(requestedMatchIndex) && requestedMatchIndex >= 0 + ? requestedMatchIndex : null; const counted = await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, @@ -292,7 +306,7 @@ export class RichTextToolbarProbe { args: { selector, text: args?.text || '', matchIndex: args?.matchIndex }, })))).filter(Boolean); if (!probes.length) return null; - const explicitMatchIndex = Number.isInteger(Number(args?.matchIndex)) && Number(args.matchIndex) >= 0; + const explicitMatchIndex = Number.isInteger(args?.matchIndex) && args.matchIndex >= 0; const matchedElementCount = probes.reduce((sum, probe) => ( sum + (explicitMatchIndex ? 1 : Math.max(1, Number(probe.selectorMatchCount) || 1)) ), 0); diff --git a/src/chrome/src/agent/tool-call-parser.js b/src/chrome/src/agent/tool-call-parser.js index 806cb04c3..f9080ec16 100644 --- a/src/chrome/src/agent/tool-call-parser.js +++ b/src/chrome/src/agent/tool-call-parser.js @@ -117,6 +117,54 @@ function parseWholeResponseJsonArray(text, allowedNames) { return parsed; } +/** + * Quote relaxed `key:` tokens only when they occur outside JSON strings and + * after an object boundary. A regular-expression replacement corrupts string + * values such as "Keep, status: pending" before JSON.parse sees them. + */ +function quoteBareJsonKeys(body) { + const source = String(body || ''); + let output = ''; + let inString = false; + let escaped = false; + + for (let i = 0; i < source.length;) { + const char = source[i]; + if (inString) { + output += char; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + i++; + continue; + } + if (char === '"') { + inString = true; + output += char; + i++; + continue; + } + if (/\w/.test(char)) { + let previous = i - 1; + while (previous >= 0 && /\s/.test(source[previous])) previous--; + if (previous < 0 || source[previous] === '{' || source[previous] === ',') { + let keyEnd = i + 1; + while (keyEnd < source.length && /\w/.test(source[keyEnd])) keyEnd++; + let colon = keyEnd; + while (colon < source.length && /\s/.test(source[colon])) colon++; + if (source[colon] === ':') { + output += `"${source.slice(i, keyEnd)}"${source.slice(keyEnd, colon + 1)}`; + i = colon + 1; + continue; + } + } + } + output += char; + i++; + } + return output; +} + function toFallbackToolCalls(objects) { return objects.map((obj, index) => ({ id: `fallback_call_${Date.now()}_${index}`, @@ -166,6 +214,11 @@ export function parseToolCallsFromText(text, allowedNames) { let match; while ((match = re.exec(text)) !== null) { const inner = match[1].trim(); + const wrappedArray = parseWholeResponseJsonArray(inner, allowedNames); + if (wrappedArray !== null) { + results.push(...wrappedArray); + continue; + } try { const obj = JSON.parse(inner); if (obj && obj.name && allowedNames.has(obj.name)) { @@ -180,13 +233,11 @@ export function parseToolCallsFromText(text, allowedNames) { let argsBody = callMatch[2] .replace(/<\|"\|>/g, '"') .replace(/<\|'\\?\|>/g, "'"); - argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":'); + argsBody = quoteBareJsonKeys(argsBody); try { const args = JSON.parse(`{${argsBody}}`); results.push({ name: toolName, arguments: args }); - } catch { - results.push({ name: toolName, arguments: {} }); - } + } catch { /* malformed arguments must never dispatch */ } } } } @@ -231,13 +282,11 @@ export function parseToolCallsFromText(text, allowedNames) { let argsBody = match[2] .replace(/<\|"\|>/g, '"') .replace(/<\|'\\?\|>/g, "'"); - argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":'); + argsBody = quoteBareJsonKeys(argsBody); try { const args = JSON.parse(`{${argsBody}}`); results.push({ name: toolName, arguments: args }); - } catch { - results.push({ name: toolName, arguments: {} }); - } + } catch { /* malformed arguments must never dispatch */ } } } diff --git a/src/chrome/src/agent/tools.js b/src/chrome/src/agent/tools.js index 3166295ed..aac5c8804 100644 --- a/src/chrome/src/agent/tools.js +++ b/src/chrome/src/agent/tools.js @@ -839,7 +839,7 @@ export const AGENT_TOOLS = [ selector: { type: 'string', description: 'CSS selector for the element to click inside the iframe.' }, matchIndex: { type: 'number', description: 'Zero-based element index from iframe_read. Omit only when the selector uniquely matches one element across the selected frames.' }, }, - required: ['selector'], + required: ['urlFilter', 'selector'], }, }, }, @@ -857,7 +857,7 @@ export const AGENT_TOOLS = [ text: { type: 'string', description: 'Text to type into the field.' }, clear: { type: 'boolean', description: 'Whether to clear the field before typing. Default false.' }, }, - required: ['selector', 'text'], + required: ['urlFilter', 'selector', 'text'], }, }, }, diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 3f8c89523..396785a85 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -605,12 +605,12 @@ function publicTeacherSession(session) { async function notifyTeacherState(tabId, session) { if (tabId == null) return false; try { - await chrome.tabs.sendMessage(tabId, { + const response = await chrome.tabs.sendMessage(tabId, { target: 'content', action: 'teacher_state', state: publicTeacherSession(session), }); - return true; + return response?.teacherCaptureReady === true; } catch { return false; } diff --git a/src/chrome/src/content/content.js b/src/chrome/src/content/content.js index df46aa04f..2938544ed 100644 --- a/src/chrome/src/content/content.js +++ b/src/chrome/src/content/content.js @@ -1530,6 +1530,13 @@ el = document.elementFromPoint(params.x, params.y); } + // A selector miss is a discovery failure, not a changed bound target. Do + // not consume the one-shot toolbar binding when no element was resolved; + // the caller's advised re-read/retry must still be able to bind a target. + if (params.selector && !el) { + return { success: false, dispatched: false, error: 'Element not found' }; + } + if (!_consumeDispatchBinding(params.dispatchBinding?.token, el)) { return { success: false, diff --git a/src/chrome/src/content/redaction-regions.js b/src/chrome/src/content/redaction-regions.js index 91b42a463..721642a6f 100644 --- a/src/chrome/src/content/redaction-regions.js +++ b/src/chrome/src/content/redaction-regions.js @@ -152,6 +152,7 @@ function waitForExactChildFrameRect(params) { const token = String(params?.token || ''); + const expectedChildOrigin = String(params?.expectedChildOrigin || ''); if (!token) return Promise.resolve({ found: false }); return new Promise(resolve => { // A single claim is answered after this quiet window so a second frame @@ -193,11 +194,24 @@ } return frames; }; + const hasOpaqueSandboxOrigin = frame => { + const sandboxValue = frame?.getAttribute?.('sandbox'); + return sandboxValue != null + && !String(sandboxValue).toLowerCase().split(/\s+/).includes('allow-same-origin'); + }; const onMessage = event => { if (event?.data?.__webbrainExactFrameRectToken !== token) return; const frame = reachableFrames() .find(candidate => candidate.contentWindow === event.source); if (!frame) return; + // The token is necessarily transported across the page/content-script + // boundary. Bind it to the exact child window and its origin. Sandboxed + // frames without allow-same-origin (and their descendants) have an + // opaque "null" origin, so accept that value only from the matching + // frame after the background has verified the sandbox chain. + const opaqueSandboxClaim = event.origin === 'null' + && (params?.allowOpaqueChildOrigin === true || hasOpaqueSandboxOrigin(frame)); + if (expectedChildOrigin && event.origin !== expectedChildOrigin && !opaqueSandboxClaim) return; // The agent announces this token to exactly one child frame, so a // second distinct claimant is a frame answering for a token that was // never sent to it. Resolving the first arrival would let it decide @@ -212,7 +226,9 @@ contentionTimer = setTimeout(() => resolveClaim(), CLAIM_CONTENTION_MS); return; } - resolveClaim(); + // Duplicate delivery from the same frame is not independent evidence. + // Keep the full contention window open so a different claimant still + // has a chance to make this lookup fail closed. }; const resolveClaim = () => { const frame = claimedFrame; @@ -250,6 +266,7 @@ name: frame.getAttribute?.('name') || null, role: frame.getAttribute?.('role') || null, }, + childOriginOpaque: params?.allowOpaqueChildOrigin === true || hasOpaqueSandboxOrigin(frame), }); }; window.addEventListener('message', onMessage); @@ -259,9 +276,13 @@ function announceExactChildFrame(params) { const token = String(params?.token || ''); + const parentOrigin = String(params?.parentOrigin || ''); if (!token || window.parent === window) return { announced: false }; try { - window.parent.postMessage({ __webbrainExactFrameRectToken: token }, '*'); + window.parent.postMessage( + { __webbrainExactFrameRectToken: token }, + parentOrigin || '*', + ); return { announced: true }; } catch { return { announced: false }; } } diff --git a/src/chrome/src/content/teacher-capture.js b/src/chrome/src/content/teacher-capture.js index 387b0c443..aab6bd520 100644 --- a/src/chrome/src/content/teacher-capture.js +++ b/src/chrome/src/content/teacher-capture.js @@ -238,7 +238,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message?.target !== 'content') return; - if (message.action === 'teacher_state') setState(message.state); + if (message.action === 'teacher_state') { + setState(message.state); + sendResponse({ teacherCaptureReady: true }); + } if (message.action === 'flush_teacher_capture') { sendResponse({ teacherAction: active ? activeFieldAction() : null }); } diff --git a/src/chrome/src/providers/fetch-with-fallback.js b/src/chrome/src/providers/fetch-with-fallback.js index 1481591d8..5aafac292 100644 --- a/src/chrome/src/providers/fetch-with-fallback.js +++ b/src/chrome/src/providers/fetch-with-fallback.js @@ -35,6 +35,7 @@ let _storageListener = null; const TIMEOUT_FLOOR_MS = 5000; // 5s — anything lower than this is a typo const TIMEOUT_CEILING_MS = 600000; // 10 min — well past any reasonable first-byte wait const OFFSCREEN_FORM_DATA_CHUNK_BYTES = 256 * 1024; +const IDEMPOTENT_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']); async function _ensureTimeoutInitialized() { if (_timeoutInitialized) return; @@ -82,6 +83,19 @@ async function _ensureTimeoutInitialized() { export async function fetchWithFallback(url, options = {}) { await _ensureTimeoutInitialized(); const { timeoutMs = _cachedTimeoutMs, signal: callerSignal, ...fetchOptions } = options; + const method = String(fetchOptions.method || 'GET').toUpperCase(); + const canUseOffscreenProxy = typeof chrome !== 'undefined' + && typeof chrome?.runtime?.connect === 'function'; + + // A network-level fetch rejection does not prove that the server never + // received the request (CORS/PNA can reject after it was processed). Sending + // a POST/PATCH again through the proxy can therefore duplicate a billed + // generation or upload. Route non-idempotent requests through exactly one + // transport from the outset; only idempotent methods use direct-then-proxy. + if (!IDEMPOTENT_HTTP_METHODS.has(method) && canUseOffscreenProxy) { + await ensureOffscreen(); + return _fetchViaOffscreenProxy(url, fetchOptions, timeoutMs, callerSignal); + } // Fast path: try direct fetch first, with a connection-phase timeout. const controller = new AbortController(); @@ -119,6 +133,7 @@ export async function fetchWithFallback(url, options = {}) { ); } if (directError.name === 'AbortError') throw directError; + if (!IDEMPOTENT_HTTP_METHODS.has(method)) throw directError; // Network error (Failed to fetch) — try offscreen proxy console.warn( diff --git a/src/chrome/src/providers/manager.js b/src/chrome/src/providers/manager.js index b9874529d..71905b319 100644 --- a/src/chrome/src/providers/manager.js +++ b/src/chrome/src/providers/manager.js @@ -14,7 +14,6 @@ import { ADDITIONAL_PROVIDER_DEFAULTS } from './provider-catalog.js'; // fetch ever ran, so onboarding reported "no models" for a reachable server.) import { fetchWithFallback } from './fetch-with-fallback.js'; import { - AUTO_VISION_PROVIDER_IDS, VISION_MODES, parseLlamaCppVisionSupport, parseLmStudioVisionSupport, @@ -644,7 +643,9 @@ export class ProviderManager { ...migrated.ollama, visionMode: OLLAMA_VISION_MODES.has(migrated.ollama.visionMode) ? migrated.ollama.visionMode - : (legacySupportsVision === false ? 'off' : 'auto'), + : (legacySupportsVision === true + ? 'on' + : (legacySupportsVision === false ? 'off' : 'auto')), }; delete migrated.ollama.supportsVision; } @@ -652,14 +653,13 @@ export class ProviderManager { if (!visionProviderKind(id, config)) continue; const legacySupportsVision = config.supportsVision; const hasConfiguredModel = !!String(config.model || '').trim(); - const isBuiltInAutoProvider = AUTO_VISION_PROVIDER_IDS.has(id); migrated[id] = { ...config, visionMode: VISION_MODES.has(config.visionMode) ? config.visionMode : (legacySupportsVision === false ? 'off' - : (legacySupportsVision === true && !isBuiltInAutoProvider ? 'on' : 'auto')), + : (legacySupportsVision === true ? 'on' : 'auto')), // With an empty Model field the server's loaded model can change // without a settings update, so never restore a persisted detection. visionDetection: hasConfiguredModel ? (config.visionDetection || null) : null, @@ -981,6 +981,11 @@ export class ProviderManager { this._ollamaVisionChecks.set(identity.key, pending); } const result = await pending; + if (!result.ok && this._ollamaVisionChecks.get(identity.key) === pending) { + // A transient timeout, network failure, or malformed response must not + // pin this model to text-only for the service worker's entire lifetime. + this._ollamaVisionChecks.delete(identity.key); + } const current = this.providers.get(id); const currentIdentity = this._ollamaVisionIdentity(current?.config); if (!current || current.config.visionMode !== 'auto' || currentIdentity?.key !== identity.key) { diff --git a/src/chrome/src/providers/provider-compatibility.js b/src/chrome/src/providers/provider-compatibility.js index a15223bdb..618319c58 100644 --- a/src/chrome/src/providers/provider-compatibility.js +++ b/src/chrome/src/providers/provider-compatibility.js @@ -64,6 +64,7 @@ export function normalizeOpenAICompatibleBaseUrl(value) { export function openAiCompatiblePayloadError(payload, maxLength = 500) { const error = payload?.error; if (!error) return ''; + if (typeof error === 'object' && !Array.isArray(error) && Object.keys(error).length === 0) return ''; const detail = typeof error === 'string' ? error : String(error.message || error.detail || JSON.stringify(error)); diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js index f3e2dd58d..c5d570b79 100644 --- a/src/chrome/src/ui/settings.js +++ b/src/chrome/src/ui/settings.js @@ -58,7 +58,7 @@ const VISION_UI_PROVIDER_IDS = new Set(['ollama', ...AUTO_VISION_PROVIDER_IDS]); // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '30.0.3'; +const EXT_VERSION = '30.0.4'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 86ab70978..482d6b586 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -11975,6 +11975,11 @@ async function restoreStagedScreenshotAttachments(root = messagesEl, tabId = ren updateAttachmentReadCount(numericTabId, 1); try { const results = Array.from(root.querySelectorAll?.('.screenshot-result') || []); + const pendingScreenshotIdsBeforeLoad = new Set(getPendingAttachmentsForTab( + numericTabId, + { create: false }, + ).filter(attachment => attachment?.source === 'slash_screenshot') + .map(attachment => attachment.stagedAttachmentId)); const storedAttachments = await loadStagedScreenshots(chrome.storage.local, numericTabId); if (!sameTabId(renderedTabId ?? currentTabId, numericTabId)) return; const pendingStoredAttachments = storedAttachments.filter(attachment => attachment.deliveryState === 'pending'); @@ -12010,6 +12015,9 @@ async function restoreStagedScreenshotAttachments(root = messagesEl, tabId = ren const pending = getPendingAttachmentsForTab(numericTabId).filter(attachment => ( attachment?.source !== 'slash_screenshot' || restoredIds.has(attachment.stagedAttachmentId) + // A capture may finish while storage is being read. It is not part of + // this restore snapshot and must remain staged for the next message. + || !pendingScreenshotIdsBeforeLoad.has(attachment.stagedAttachmentId) )); for (const { result, attachment } of restored) { const image = result.querySelector('.screenshot-result-image'); diff --git a/src/chrome/src/ui/tab-chat-persistence.js b/src/chrome/src/ui/tab-chat-persistence.js index c73702b6f..85f03189f 100644 --- a/src/chrome/src/ui/tab-chat-persistence.js +++ b/src/chrome/src/ui/tab-chat-persistence.js @@ -120,6 +120,28 @@ export function compactTabChatForPersist(html, budget = TAB_CHAT_PERSIST_BUDGET) return fallback.slice(0, boundedBudget); } +async function tabChatKeyBelongsToOpenTab(storedKey) { + const rawTabId = String(storedKey || '').slice(TAB_CHAT_PREFIX.length); + const tabId = Number(rawTabId); + const tabs = globalThis.browser?.tabs || globalThis.chrome?.tabs; + // Without a tab API there is no proof that removal is safe. + if (!Number.isFinite(tabId) || !tabs?.get) return true; + try { + await tabs.get(tabId); + return true; + } catch (error) { + // Only the browser's explicit "tab does not exist" errors prove closure. + // A transient API/context failure leaves ownership unknown and must retain + // the other tab's chat. + const message = String(error?.message || error || '').toLowerCase(); + return !( + /no tab with id\b/.test(message) + || /invalid tab id\b/.test(message) + || /no such tab\b/.test(message) + ); + } +} + export async function persistTabChatToSession(storageArea, key, html, warn = console.warn) { const source = String(html || ''); const initialValue = source.length > TAB_CHAT_PERSIST_BUDGET @@ -141,6 +163,39 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con retryError = error; } + try { + // Tab-close cleanup can be interrupted by a service-worker shutdown. + // Reclaim only chats whose tab is provably gone; never delete another + // open tab's history merely to make the current write fit. + const stored = await storageArea.get(null); + const candidates = Object.entries(stored || {}) + .filter(([storedKey, value]) => ( + storedKey !== key + && storedKey.startsWith(TAB_CHAT_PREFIX) + && typeof value === 'string' + )) + .sort((a, b) => b[1].length - a[1].length); + const evictedKeys = []; + for (const [storedKey] of candidates) { + if (await tabChatKeyBelongsToOpenTab(storedKey)) continue; + await storageArea.remove(storedKey); + evictedKeys.push(storedKey); + try { + await storageArea.set({ [key]: retryValue }); + return { + ok: true, + degraded: true, + recoveredFromQuota: true, + evictedKeys, + }; + } catch (error) { + retryError = error; + } + } + } catch (error) { + retryError = error; + } + try { warn( '[WebBrain] persistTabChat: session storage write failed after compacting the stored copy; chat may not survive a panel reopen:', diff --git a/src/firefox/ARCHITECTURE.md b/src/firefox/ARCHITECTURE.md index 225fc1443..d1bdb22e6 100644 --- a/src/firefox/ARCHITECTURE.md +++ b/src/firefox/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Firefox Extension — Architecture -> Version 30.0.3 · Manifest V2 · Background Page +> Version 30.0.4 · Manifest V2 · Background Page ## How Firefox Differs from Chrome diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json index 847c22b1f..16382fe17 100644 --- a/src/firefox/manifest.json +++ b/src/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "WebBrain", - "version": "30.0.3", + "version": "30.0.4", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "activeTab", diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 3cb03213e..2b2550824 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -242,7 +242,7 @@ function captchaCandidateMatchesGate(candidate, identity) { const candidatePath = captchaCandidateFramePath(candidate); if ( expectedPath.length !== candidatePath.length - || expectedPath.some((value, index) => value !== candidatePath[index]) + || expectedPath.some((value, index) => !Object.is(value, candidatePath[index])) ) return false; const expectedFieldIds = [identity.responseFieldId, identity.alsoResponseFieldId] @@ -2324,6 +2324,13 @@ export class Agent extends LoopDetector { if (!RICH_TEXT_TOOLBAR_GUARDED_TOOLS.has(toolName)) return null; const probe = await this._probeRichTextToolbarRetryTarget(tabId, toolName, args); if (!probe?.resolved) { + const knownRefBlock = this._richTextToolbarGuard.blockRef( + tabId, + toolName, + args, + this._lastAxScopes.get(tabId)?.documentToken || '', + ); + if (knownRefBlock) return knownRefBlock; return DISPATCH_BINDING_TOOLS.has(toolName) ? { success: false, @@ -17918,7 +17925,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (name === 'iframe_click') { let dispatched = false; try { - const urlFilter = args.urlFilter || ''; + const urlFilter = String(args.urlFilter || '').trim(); const selector = args.selector; if (!selector) { return { @@ -17928,6 +17935,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'selector is required', }; } + if (!urlFilter) { + return { + success: false, + dispatched: false, + noDispatch: true, + error: 'urlFilter is required so the iframe target and permission scope remain stable', + }; + } const hasExplicitMatchIndex = args.matchIndex !== undefined && args.matchIndex !== null; const requestedMatchIndex = hasExplicitMatchIndex ? Number(args.matchIndex) : 0; if (!Number.isInteger(requestedMatchIndex) || requestedMatchIndex < 0) { @@ -18051,6 +18066,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let dispatched = false; try { const selector = args.selector; + const urlFilter = String(args.urlFilter || '').trim(); const text = args.text || ''; const clear = !!args.clear; if (!selector) { @@ -18061,6 +18077,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d error: 'selector is required', }; } + if (!urlFilter) { + return { + success: false, + dispatched: false, + noDispatch: true, + error: 'urlFilter is required so the iframe target and its verification scope remain stable', + }; + } let binding = dispatchBinding; let targetFrameId = binding?.frameId; // The preflight sweeps every frame; when it already reported that no @@ -18092,7 +18116,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d selector, text, clear, - urlFilter: args.urlFilter || '', + urlFilter, matchIndex: args.matchIndex, }); } diff --git a/src/firefox/src/agent/completion-invariant.js b/src/firefox/src/agent/completion-invariant.js index e19dfac18..fd4634341 100644 --- a/src/firefox/src/agent/completion-invariant.js +++ b/src/firefox/src/agent/completion-invariant.js @@ -93,14 +93,17 @@ function normalizedIframeMatchIndex(value) { function iframeFormObligation(args = {}, result = {}) { const selector = String(args?.selector || '').trim(); + const scope = normalizedIframeScope(args?.urlFilter); const finalValue = typeof result?.value === 'string' ? result.value : (typeof result?.frame?.value === 'string' ? result.frame.value : null); - if (!selector) return null; + // verify_form addresses iframe targets by urlFilter. Never create debt that + // the verification tool has no stable scope with which to discharge it. + if (!selector || !scope) return null; const matchMode = finalValue !== null || args?.clear === true ? 'exact' : 'suffix'; const rawExpectedValue = String(finalValue ?? args?.text ?? ''); return { - scope: normalizedIframeScope(args?.urlFilter), + scope, frameId: Number.isInteger(result?.frameId) ? result.frameId : null, selector, matchIndex: normalizedIframeMatchIndex(result?.matchIndex ?? args?.matchIndex), diff --git a/src/firefox/src/agent/loop-detector.js b/src/firefox/src/agent/loop-detector.js index 546662856..0fdb56b6c 100644 --- a/src/firefox/src/agent/loop-detector.js +++ b/src/firefox/src/agent/loop-detector.js @@ -313,7 +313,6 @@ export class LoopDetector { refId: typeof value?.ref_id === 'string' ? value.ref_id.trim() : '', }); const page = Number(args?.page || 1); - const hasRef = typeof args?.ref_id === 'string' && args.ref_id.trim() !== ''; const currentScopeKey = scopeKeyFor(args); const currentPageKey = `${currentScopeKey}|${page}`; const sequentialPage = previous.total > 0 @@ -321,10 +320,14 @@ export class LoopDetector { && page === previous.nextPage && currentScopeKey === previous.scopeKey && !previous.seenPages.has(currentPageKey); - const repeatedRootOrPage = previous.total > 0 && !sequentialPage; - const content = String(result?.pageContent || '').trim(); - const meaningfulLines = content ? content.split(/\r?\n/).filter(line => line.trim()).length : 0; - const suspicious = !sequentialPage && (hasRef || repeatedRootOrPage || (hasRef && meaningfulLines <= 1)); + const repeatedScopeOutOfSequence = previous.total > 0 + && currentScopeKey === previous.scopeKey + && !sequentialPage; + const repeatedExactPage = previous.seenPages.has(currentPageKey); + // A first read of a new ref-anchored subtree is legitimate drill-down + // progress. Only repeated/out-of-order reads of the same scope are loop + // evidence; the total-read cap below still bounds endless ref hopping. + const suspicious = !sequentialPage && (repeatedExactPage || repeatedScopeOutOfSequence); const state = { total: previous.total + 1, diff --git a/src/firefox/src/agent/mutation-tools.js b/src/firefox/src/agent/mutation-tools.js index 599621788..603aaa132 100644 --- a/src/firefox/src/agent/mutation-tools.js +++ b/src/firefox/src/agent/mutation-tools.js @@ -10,16 +10,15 @@ /** Tools that change page or browser state, gating auto-screenshots and * unknown-outcome normalization as well as loop detection. */ -export const STATE_CHANGE_TOOLS = new Set(['navigate', 'promote_iframe', 'new_tab', 'go_back', 'go_forward', 'click', 'click_ax', 'set_checked', 'type_text', 'type_ax', 'set_field', 'iframe_type', 'press_keys', 'scroll', 'hover', 'drag_drop', 'execute_js']); +export const STATE_CHANGE_TOOLS = new Set(['navigate', 'promote_iframe', 'new_tab', 'go_back', 'go_forward', 'click', 'click_ax', 'set_checked', 'iframe_click', 'type_text', 'type_ax', 'set_field', 'iframe_type', 'press_keys', 'scroll', 'hover', 'drag_drop', 'execute_js']); /** * Everything the failed-action loop counters treat as a browser mutation. - * Adds the remaining frame- and upload-scoped tools that act on the page but are not + * Adds the remaining upload- and challenge-scoped tools that act on the page but are not * part of the auto-screenshot state-change set. */ export const BROWSER_MUTATION_TOOLS = new Set([ ...STATE_CHANGE_TOOLS, - 'iframe_click', 'upload_file', 'solve_captcha', ]); diff --git a/src/firefox/src/agent/planner.js b/src/firefox/src/agent/planner.js index a4a576bfa..59546c9ee 100644 --- a/src/firefox/src/agent/planner.js +++ b/src/firefox/src/agent/planner.js @@ -527,9 +527,13 @@ export function normalizePlan(obj, opts = {}) { const normalizedScheduling = tool === 'schedule_task' || tool === 'schedule_resume' ? { tool, hint: sanitizeText(scheduling.hint, 300) } : null; - const risks = Array.isArray(obj.risks) - ? obj.risks.map((risk) => sanitizeText(risk, 200)).filter(Boolean).slice(0, 6) + const riskEntries = Array.isArray(obj.risks) + ? obj.risks + .map((risk, sourceIndex) => ({ sourceIndex, text: sanitizeText(risk, 200) })) + .filter(entry => entry.text) + .slice(0, 6) : []; + const risks = riskEntries.map(entry => entry.text); const localizedInput = obj.localized && typeof obj.localized === 'object' ? obj.localized : {}; const providedLocalizedSteps = Array.isArray(localizedInput.steps) ? localizedInput.steps.slice(0, 12).map((step, i) => ({ @@ -545,7 +549,7 @@ export function normalizePlan(obj, opts = {}) { || step.action, })); const providedLocalizedRisks = Array.isArray(localizedInput.risks) - ? localizedInput.risks.slice(0, 6).map((risk) => sanitizeText(risk, 200)) + ? localizedInput.risks : []; const requestedLocale = normalizePlannerLocale(opts.locale || localizedInput.locale); if (opts.requireIntent) { @@ -556,7 +560,9 @@ export function normalizePlan(obj, opts = {}) { locale: requestedLocale, summary: localizedSummary || summary, steps: localizedSteps, - risks: risks.map((risk, index) => providedLocalizedRisks[index] || risk), + risks: riskEntries.map(({ sourceIndex, text: risk }) => ( + sanitizeText(providedLocalizedRisks[sourceIndex], 200) || risk + )), }; const submissionBearingPlan = executablePlan || requestKind === 'clarify'; const requiresSubmission = submissionBearingPlan diff --git a/src/firefox/src/agent/rich-text-toolbar-probe.js b/src/firefox/src/agent/rich-text-toolbar-probe.js index d50d0e029..87ba42a2c 100644 --- a/src/firefox/src/agent/rich-text-toolbar-probe.js +++ b/src/firefox/src/agent/rich-text-toolbar-probe.js @@ -19,6 +19,15 @@ function withDispatchBinding(probe, frameId = probe?.frameId) { }; } +function messageOrigin(url) { + try { + const origin = new URL(String(url || '')).origin; + return origin && origin !== 'null' ? origin : ''; + } catch { + return ''; + } +} + export class RichTextToolbarProbe { constructor(agent) { this.agent = agent; @@ -69,19 +78,23 @@ export class RichTextToolbarProbe { child = parent; } if (!child || child.frameId !== 0) return null; + const opaqueFrameIds = new Set(); const exactChildRect = async edge => { const token = `wb-frame-${Date.now()}-${secureRandomBase36Token(12)}`; + const parentOriginOpaque = opaqueFrameIds.has(edge.parent.frameId); + const parentOrigin = parentOriginOpaque ? '' : messageOrigin(edge.parent.url); + const expectedChildOrigin = messageOrigin(edge.child.url); const parentResponse = browser.tabs.sendMessage(tabId, { target: 'redaction-content', action: 'wait_for_exact_child_frame_rect', - params: { token, scrollIntoView: true }, + params: { token, expectedChildOrigin, allowOpaqueChildOrigin: parentOriginOpaque, scrollIntoView: true }, }, { frameId: edge.parent.frameId }).catch(() => null); await new Promise(resolve => setTimeout(resolve, 0)); try { await browser.tabs.sendMessage(tabId, { target: 'redaction-content', action: 'announce_exact_child_frame', - params: { token }, + params: { token, parentOrigin }, }, { frameId: edge.child.frameId }); } catch {} return parentResponse; @@ -91,6 +104,7 @@ export class RichTextToolbarProbe { let frameOwnerMeta = null; for (const edge of edges) { const exact = await exactChildRect(edge); + if (exact?.childOriginOpaque === true) opaqueFrameIds.add(edge.child.frameId); const parentTransform = transforms.get(edge.parent.frameId); const childSnapshot = snapshotById.get(edge.child.frameId); const childWidth = Number(childSnapshot?.viewport?.width); @@ -159,8 +173,8 @@ export class RichTextToolbarProbe { } async legacyIframeTypeAllFrames(tabId, { selector, text, clear, urlFilter, matchIndex: requestedMatchIndex }) { - const matchIndex = Number.isInteger(Number(requestedMatchIndex)) && Number(requestedMatchIndex) >= 0 - ? Number(requestedMatchIndex) + const matchIndex = Number.isInteger(requestedMatchIndex) && requestedMatchIndex >= 0 + ? requestedMatchIndex : null; let navigationFrames = []; try { navigationFrames = await browser.webNavigation.getAllFrames({ tabId }); } catch {} @@ -302,7 +316,7 @@ export class RichTextToolbarProbe { args: { selector, text: args?.text || '', matchIndex: args?.matchIndex }, })))).filter(Boolean); if (!probes.length) return null; - const explicitMatchIndex = Number.isInteger(Number(args?.matchIndex)) && Number(args.matchIndex) >= 0; + const explicitMatchIndex = Number.isInteger(args?.matchIndex) && args.matchIndex >= 0; const matchedElementCount = probes.reduce((sum, probe) => ( sum + (explicitMatchIndex ? 1 : Math.max(1, Number(probe.selectorMatchCount) || 1)) ), 0); diff --git a/src/firefox/src/agent/tool-call-parser.js b/src/firefox/src/agent/tool-call-parser.js index 806cb04c3..f9080ec16 100644 --- a/src/firefox/src/agent/tool-call-parser.js +++ b/src/firefox/src/agent/tool-call-parser.js @@ -117,6 +117,54 @@ function parseWholeResponseJsonArray(text, allowedNames) { return parsed; } +/** + * Quote relaxed `key:` tokens only when they occur outside JSON strings and + * after an object boundary. A regular-expression replacement corrupts string + * values such as "Keep, status: pending" before JSON.parse sees them. + */ +function quoteBareJsonKeys(body) { + const source = String(body || ''); + let output = ''; + let inString = false; + let escaped = false; + + for (let i = 0; i < source.length;) { + const char = source[i]; + if (inString) { + output += char; + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + i++; + continue; + } + if (char === '"') { + inString = true; + output += char; + i++; + continue; + } + if (/\w/.test(char)) { + let previous = i - 1; + while (previous >= 0 && /\s/.test(source[previous])) previous--; + if (previous < 0 || source[previous] === '{' || source[previous] === ',') { + let keyEnd = i + 1; + while (keyEnd < source.length && /\w/.test(source[keyEnd])) keyEnd++; + let colon = keyEnd; + while (colon < source.length && /\s/.test(source[colon])) colon++; + if (source[colon] === ':') { + output += `"${source.slice(i, keyEnd)}"${source.slice(keyEnd, colon + 1)}`; + i = colon + 1; + continue; + } + } + } + output += char; + i++; + } + return output; +} + function toFallbackToolCalls(objects) { return objects.map((obj, index) => ({ id: `fallback_call_${Date.now()}_${index}`, @@ -166,6 +214,11 @@ export function parseToolCallsFromText(text, allowedNames) { let match; while ((match = re.exec(text)) !== null) { const inner = match[1].trim(); + const wrappedArray = parseWholeResponseJsonArray(inner, allowedNames); + if (wrappedArray !== null) { + results.push(...wrappedArray); + continue; + } try { const obj = JSON.parse(inner); if (obj && obj.name && allowedNames.has(obj.name)) { @@ -180,13 +233,11 @@ export function parseToolCallsFromText(text, allowedNames) { let argsBody = callMatch[2] .replace(/<\|"\|>/g, '"') .replace(/<\|'\\?\|>/g, "'"); - argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":'); + argsBody = quoteBareJsonKeys(argsBody); try { const args = JSON.parse(`{${argsBody}}`); results.push({ name: toolName, arguments: args }); - } catch { - results.push({ name: toolName, arguments: {} }); - } + } catch { /* malformed arguments must never dispatch */ } } } } @@ -231,13 +282,11 @@ export function parseToolCallsFromText(text, allowedNames) { let argsBody = match[2] .replace(/<\|"\|>/g, '"') .replace(/<\|'\\?\|>/g, "'"); - argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":'); + argsBody = quoteBareJsonKeys(argsBody); try { const args = JSON.parse(`{${argsBody}}`); results.push({ name: toolName, arguments: args }); - } catch { - results.push({ name: toolName, arguments: {} }); - } + } catch { /* malformed arguments must never dispatch */ } } } diff --git a/src/firefox/src/agent/tools.js b/src/firefox/src/agent/tools.js index ae163e927..60a18161b 100644 --- a/src/firefox/src/agent/tools.js +++ b/src/firefox/src/agent/tools.js @@ -686,7 +686,7 @@ export const AGENT_TOOLS = [ selector: { type: 'string', description: 'CSS selector for the element to click inside the iframe.' }, matchIndex: { type: 'number', description: 'Zero-based element index from iframe_read. Omit only when the selector uniquely matches one element across the selected frames.' }, }, - required: ['selector'], + required: ['urlFilter', 'selector'], }, }, }, @@ -704,7 +704,7 @@ export const AGENT_TOOLS = [ text: { type: 'string', description: 'Text to type into the field.' }, clear: { type: 'boolean', description: 'Whether to clear the field before typing.' }, }, - required: ['selector', 'text'], + required: ['urlFilter', 'selector', 'text'], }, }, }, diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index 3c3ce398c..ed7228eaa 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -567,12 +567,12 @@ function publicTeacherSession(session) { async function notifyTeacherState(tabId, session) { if (tabId == null) return false; try { - await browser.tabs.sendMessage(tabId, { + const response = await browser.tabs.sendMessage(tabId, { target: 'content', action: 'teacher_state', state: publicTeacherSession(session), }); - return true; + return response?.teacherCaptureReady === true; } catch { return false; } diff --git a/src/firefox/src/content/content.js b/src/firefox/src/content/content.js index 27a41842b..f0f2068e4 100644 --- a/src/firefox/src/content/content.js +++ b/src/firefox/src/content/content.js @@ -1837,6 +1837,13 @@ el = document.elementFromPoint(params.x, params.y); } + // A selector miss is a discovery failure, not a changed bound target. Do + // not consume the one-shot toolbar binding when no element was resolved; + // the caller's advised re-read/retry must still be able to bind a target. + if (params.selector && !el) { + return { success: false, dispatched: false, error: 'Element not found' }; + } + if (!_consumeDispatchBinding(params.dispatchBinding?.token, el)) { return { success: false, diff --git a/src/firefox/src/content/redaction-regions.js b/src/firefox/src/content/redaction-regions.js index 91b42a463..721642a6f 100644 --- a/src/firefox/src/content/redaction-regions.js +++ b/src/firefox/src/content/redaction-regions.js @@ -152,6 +152,7 @@ function waitForExactChildFrameRect(params) { const token = String(params?.token || ''); + const expectedChildOrigin = String(params?.expectedChildOrigin || ''); if (!token) return Promise.resolve({ found: false }); return new Promise(resolve => { // A single claim is answered after this quiet window so a second frame @@ -193,11 +194,24 @@ } return frames; }; + const hasOpaqueSandboxOrigin = frame => { + const sandboxValue = frame?.getAttribute?.('sandbox'); + return sandboxValue != null + && !String(sandboxValue).toLowerCase().split(/\s+/).includes('allow-same-origin'); + }; const onMessage = event => { if (event?.data?.__webbrainExactFrameRectToken !== token) return; const frame = reachableFrames() .find(candidate => candidate.contentWindow === event.source); if (!frame) return; + // The token is necessarily transported across the page/content-script + // boundary. Bind it to the exact child window and its origin. Sandboxed + // frames without allow-same-origin (and their descendants) have an + // opaque "null" origin, so accept that value only from the matching + // frame after the background has verified the sandbox chain. + const opaqueSandboxClaim = event.origin === 'null' + && (params?.allowOpaqueChildOrigin === true || hasOpaqueSandboxOrigin(frame)); + if (expectedChildOrigin && event.origin !== expectedChildOrigin && !opaqueSandboxClaim) return; // The agent announces this token to exactly one child frame, so a // second distinct claimant is a frame answering for a token that was // never sent to it. Resolving the first arrival would let it decide @@ -212,7 +226,9 @@ contentionTimer = setTimeout(() => resolveClaim(), CLAIM_CONTENTION_MS); return; } - resolveClaim(); + // Duplicate delivery from the same frame is not independent evidence. + // Keep the full contention window open so a different claimant still + // has a chance to make this lookup fail closed. }; const resolveClaim = () => { const frame = claimedFrame; @@ -250,6 +266,7 @@ name: frame.getAttribute?.('name') || null, role: frame.getAttribute?.('role') || null, }, + childOriginOpaque: params?.allowOpaqueChildOrigin === true || hasOpaqueSandboxOrigin(frame), }); }; window.addEventListener('message', onMessage); @@ -259,9 +276,13 @@ function announceExactChildFrame(params) { const token = String(params?.token || ''); + const parentOrigin = String(params?.parentOrigin || ''); if (!token || window.parent === window) return { announced: false }; try { - window.parent.postMessage({ __webbrainExactFrameRectToken: token }, '*'); + window.parent.postMessage( + { __webbrainExactFrameRectToken: token }, + parentOrigin || '*', + ); return { announced: true }; } catch { return { announced: false }; } } diff --git a/src/firefox/src/content/teacher-capture.js b/src/firefox/src/content/teacher-capture.js index 387b0c443..aab6bd520 100644 --- a/src/firefox/src/content/teacher-capture.js +++ b/src/firefox/src/content/teacher-capture.js @@ -238,7 +238,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message?.target !== 'content') return; - if (message.action === 'teacher_state') setState(message.state); + if (message.action === 'teacher_state') { + setState(message.state); + sendResponse({ teacherCaptureReady: true }); + } if (message.action === 'flush_teacher_capture') { sendResponse({ teacherAction: active ? activeFieldAction() : null }); } diff --git a/src/firefox/src/providers/manager.js b/src/firefox/src/providers/manager.js index e581675b6..bf3c04842 100644 --- a/src/firefox/src/providers/manager.js +++ b/src/firefox/src/providers/manager.js @@ -8,7 +8,6 @@ import { AwsBedrockProvider } from './aws-bedrock.js'; import { ADDITIONAL_PROVIDER_DEFAULTS } from './provider-catalog.js'; import { fetchWithTimeout } from './fetch-timeout.js'; import { - AUTO_VISION_PROVIDER_IDS, VISION_MODES, parseLlamaCppVisionSupport, parseLmStudioVisionSupport, @@ -623,7 +622,9 @@ export class ProviderManager { ...migrated.ollama, visionMode: OLLAMA_VISION_MODES.has(migrated.ollama.visionMode) ? migrated.ollama.visionMode - : (legacySupportsVision === false ? 'off' : 'auto'), + : (legacySupportsVision === true + ? 'on' + : (legacySupportsVision === false ? 'off' : 'auto')), }; delete migrated.ollama.supportsVision; } @@ -631,14 +632,13 @@ export class ProviderManager { if (!visionProviderKind(id, config)) continue; const legacySupportsVision = config.supportsVision; const hasConfiguredModel = !!String(config.model || '').trim(); - const isBuiltInAutoProvider = AUTO_VISION_PROVIDER_IDS.has(id); migrated[id] = { ...config, visionMode: VISION_MODES.has(config.visionMode) ? config.visionMode : (legacySupportsVision === false ? 'off' - : (legacySupportsVision === true && !isBuiltInAutoProvider ? 'on' : 'auto')), + : (legacySupportsVision === true ? 'on' : 'auto')), visionDetection: hasConfiguredModel ? (config.visionDetection || null) : null, }; delete migrated[id].supportsVision; @@ -954,6 +954,11 @@ export class ProviderManager { this._ollamaVisionChecks.set(identity.key, pending); } const result = await pending; + if (!result.ok && this._ollamaVisionChecks.get(identity.key) === pending) { + // A transient timeout, network failure, or malformed response must not + // pin this model to text-only for the background page's entire lifetime. + this._ollamaVisionChecks.delete(identity.key); + } const current = this.providers.get(id); const currentIdentity = this._ollamaVisionIdentity(current?.config); if (!current || current.config.visionMode !== 'auto' || currentIdentity?.key !== identity.key) { diff --git a/src/firefox/src/providers/provider-compatibility.js b/src/firefox/src/providers/provider-compatibility.js index a15223bdb..618319c58 100644 --- a/src/firefox/src/providers/provider-compatibility.js +++ b/src/firefox/src/providers/provider-compatibility.js @@ -64,6 +64,7 @@ export function normalizeOpenAICompatibleBaseUrl(value) { export function openAiCompatiblePayloadError(payload, maxLength = 500) { const error = payload?.error; if (!error) return ''; + if (typeof error === 'object' && !Array.isArray(error) && Object.keys(error).length === 0) return ''; const detail = typeof error === 'string' ? error : String(error.message || error.detail || JSON.stringify(error)); diff --git a/src/firefox/src/ui/settings.js b/src/firefox/src/ui/settings.js index 65678576f..5a24679fc 100644 --- a/src/firefox/src/ui/settings.js +++ b/src/firefox/src/ui/settings.js @@ -58,7 +58,7 @@ const VISION_UI_PROVIDER_IDS = new Set(['ollama', ...AUTO_VISION_PROVIDER_IDS]); // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '30.0.3'; +const EXT_VERSION = '30.0.4'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index fc6300268..25e9da5ec 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -11530,6 +11530,11 @@ async function restoreStagedScreenshotAttachments(root = messagesEl, tabId = ren updateAttachmentReadCount(numericTabId, 1); try { const results = Array.from(root.querySelectorAll?.('.screenshot-result') || []); + const pendingScreenshotIdsBeforeLoad = new Set(getPendingAttachmentsForTab( + numericTabId, + { create: false }, + ).filter(attachment => attachment?.source === 'slash_screenshot') + .map(attachment => attachment.stagedAttachmentId)); const storedAttachments = await loadStagedScreenshots(browser.storage.local, numericTabId); if (!sameTabId(renderedTabId ?? currentTabId, numericTabId)) return; const pendingStoredAttachments = storedAttachments.filter(attachment => attachment.deliveryState === 'pending'); @@ -11565,6 +11570,9 @@ async function restoreStagedScreenshotAttachments(root = messagesEl, tabId = ren const pending = getPendingAttachmentsForTab(numericTabId).filter(attachment => ( attachment?.source !== 'slash_screenshot' || restoredIds.has(attachment.stagedAttachmentId) + // A capture may finish while storage is being read. It is not part of + // this restore snapshot and must remain staged for the next message. + || !pendingScreenshotIdsBeforeLoad.has(attachment.stagedAttachmentId) )); for (const { result, attachment } of restored) { const image = result.querySelector('.screenshot-result-image'); diff --git a/src/firefox/src/ui/tab-chat-persistence.js b/src/firefox/src/ui/tab-chat-persistence.js index 09c234347..890179c66 100644 --- a/src/firefox/src/ui/tab-chat-persistence.js +++ b/src/firefox/src/ui/tab-chat-persistence.js @@ -120,6 +120,28 @@ export function compactTabChatForPersist(html, budget = TAB_CHAT_PERSIST_BUDGET) return fallback.slice(0, boundedBudget); } +async function tabChatKeyBelongsToOpenTab(storedKey) { + const rawTabId = String(storedKey || '').slice(TAB_CHAT_PREFIX.length); + const tabId = Number(rawTabId); + const tabs = globalThis.browser?.tabs || globalThis.chrome?.tabs; + // Without a tab API there is no proof that removal is safe. + if (!Number.isFinite(tabId) || !tabs?.get) return true; + try { + await tabs.get(tabId); + return true; + } catch (error) { + // Only the browser's explicit "tab does not exist" errors prove closure. + // A transient API/context failure leaves ownership unknown and must retain + // the other tab's chat. + const message = String(error?.message || error || '').toLowerCase(); + return !( + /no tab with id\b/.test(message) + || /invalid tab id\b/.test(message) + || /no such tab\b/.test(message) + ); + } +} + export async function persistTabChatToSession(storageArea, key, html, warn = console.warn) { const source = String(html || ''); const initialValue = source.length > TAB_CHAT_PERSIST_BUDGET @@ -141,6 +163,39 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con retryError = error; } + try { + // Tab-close cleanup can be interrupted by a background-page shutdown. + // Reclaim only chats whose tab is provably gone; never delete another + // open tab's history merely to make the current write fit. + const stored = await storageArea.get(null); + const candidates = Object.entries(stored || {}) + .filter(([storedKey, value]) => ( + storedKey !== key + && storedKey.startsWith(TAB_CHAT_PREFIX) + && typeof value === 'string' + )) + .sort((a, b) => b[1].length - a[1].length); + const evictedKeys = []; + for (const [storedKey] of candidates) { + if (await tabChatKeyBelongsToOpenTab(storedKey)) continue; + await storageArea.remove(storedKey); + evictedKeys.push(storedKey); + try { + await storageArea.set({ [key]: retryValue }); + return { + ok: true, + degraded: true, + recoveredFromQuota: true, + evictedKeys, + }; + } catch (error) { + retryError = error; + } + } + } catch (error) { + retryError = error; + } + try { warn( '[WebBrain] persistTabChat: session storage write failed after compacting the stored copy; chat may not survive a panel reopen:', diff --git a/test/run.js b/test/run.js index 1a458af44..ce2950035 100644 --- a/test/run.js +++ b/test/run.js @@ -1533,6 +1533,210 @@ test('redaction collectors filter offscreen fields and report incomplete region } }); +test('exact iframe geometry claims stay source-bound, allow opaque sandbox origins, and keep their contention window', () => { + for (const browserName of ['chrome', 'firefox']) { + const source = fs.readFileSync(path.join(ROOT, `src/${browserName}/src/content/redaction-regions.js`), 'utf8'); + const sourceBinding = source.indexOf('.find(candidate => candidate.contentWindow === event.source)'); + const originBinding = source.indexOf('event.origin !== expectedChildOrigin', sourceBinding); + assert.ok(sourceBinding >= 0 && originBinding > sourceBinding, + `${browserName}: frame claims must bind the exact source before applying origin exceptions`); + assert.match(source, /event\.origin === 'null'[\s\S]*allowOpaqueChildOrigin === true \|\| hasOpaqueSandboxOrigin\(frame\)/, + `${browserName}: opaque origins should be limited to sandboxed frames without allow-same-origin`); + assert.match(source, /event\.origin !== expectedChildOrigin && !opaqueSandboxClaim/, + `${browserName}: frame claims must verify the child origin`); + assert.match(source, /parentOrigin \|\| '\*'/, + `${browserName}: child announcements must target a known parent origin when available`); + assert.match(source, /if \(!claimant\) \{[\s\S]*contentionTimer = setTimeout[\s\S]*return;[\s\S]*Duplicate delivery from the same frame/, + `${browserName}: duplicate same-source claims must not resolve before contention expires`); + } +}); + +test('exact iframe geometry accepts null origins only for opaque sandboxed source frames', async () => { + for (const browserName of ['chrome', 'firefox']) { + const source = fs.readFileSync(path.join(ROOT, `src/${browserName}/src/content/redaction-regions.js`), 'utf8'); + const claim = async ({ eventOrigin, sandboxValue, allowOpaqueChildOrigin = false }) => { + let runtimeListener = null; + let windowMessageListener = null; + const timers = []; + const setTestTimeout = (callback, delay) => { + const timer = { callback, delay, active: true }; + timers.push(timer); + return timer; + }; + const clearTestTimeout = timer => { + if (timer) timer.active = false; + }; + const runTimer = delay => { + const timer = timers.find(candidate => candidate.active && candidate.delay === delay); + assert.ok(timer, `${browserName}: expected a ${delay}ms claim timer`); + timer.active = false; + timer.callback(); + }; + const childWindow = {}; + const frame = { + contentWindow: childWindow, + isConnected: true, + tagName: 'IFRAME', + id: 'sandbox-child', + offsetWidth: 120, + offsetHeight: 80, + clientWidth: 120, + clientHeight: 80, + clientLeft: 0, + clientTop: 0, + getAttribute(name) { + if (name === 'sandbox') return sandboxValue; + return null; + }, + getBoundingClientRect() { + return { left: 10, top: 20, right: 130, bottom: 100, width: 120, height: 80 }; + }, + }; + const runtime = { onMessage: { addListener(listener) { runtimeListener = listener; } } }; + const context = { + chrome: { runtime }, + browser: browserName === 'firefox' ? { runtime } : undefined, + window: { + innerWidth: 800, + innerHeight: 600, + scrollX: 0, + scrollY: 0, + pageXOffset: 0, + pageYOffset: 0, + addEventListener(type, listener) { + if (type === 'message') windowMessageListener = listener; + }, + removeEventListener(type, listener) { + if (type === 'message' && windowMessageListener === listener) windowMessageListener = null; + }, + }, + document: { + querySelectorAll(selector) { + if (selector === 'iframe, frame') return [frame]; + return []; + }, + }, + setTimeout: setTestTimeout, + clearTimeout: clearTestTimeout, + }; + vm.runInNewContext(source, context); + assert.equal(typeof runtimeListener, 'function', `${browserName}: collector listener should register`); + const resultPromise = new Promise(resolve => { + const pending = runtimeListener({ + target: 'redaction-content', + action: 'wait_for_exact_child_frame_rect', + params: { token: 'opaque-token', expectedChildOrigin: 'https://child.test', allowOpaqueChildOrigin }, + }, null, resolve); + assert.equal(pending, true, `${browserName}: exact-frame request should stay asynchronous`); + }); + assert.equal(typeof windowMessageListener, 'function', `${browserName}: message listener should register`); + windowMessageListener({ + data: { __webbrainExactFrameRectToken: 'opaque-token' }, + origin: eventOrigin, + source: childWindow, + }); + const accepted = timers.some(timer => timer.active && timer.delay === 30); + runTimer(accepted ? 30 : 750); + return resultPromise; + }; + + assert.equal( + (await claim({ eventOrigin: 'null', sandboxValue: 'allow-scripts' })).found, + true, + `${browserName}: an opaque sandbox frame should resolve its exact geometry`, + ); + assert.equal( + (await claim({ eventOrigin: 'null', sandboxValue: null })).found, + false, + `${browserName}: an unsandboxed frame should not bypass its expected origin`, + ); + assert.equal( + (await claim({ eventOrigin: 'null', sandboxValue: 'allow-scripts allow-same-origin' })).found, + false, + `${browserName}: allow-same-origin frames should not use the opaque-origin exception`, + ); + const inheritedOpaque = await claim({ + eventOrigin: 'null', + sandboxValue: null, + allowOpaqueChildOrigin: true, + }); + assert.equal(inheritedOpaque.found, true, + `${browserName}: a child of an opaque sandbox frame should inherit the origin exception`); + assert.equal(inheritedOpaque.childOriginOpaque, true, + `${browserName}: inherited opacity should propagate to deeper descendants`); + assert.equal( + (await claim({ eventOrigin: 'https://child.test', sandboxValue: null })).found, + true, + `${browserName}: ordinary exact-origin claims should remain valid`, + ); + } +}); + +test('rich-text frame geometry uses wildcard delivery only for known opaque sandbox parents', async () => { + const originalChrome = globalThis.chrome; + const originalBrowser = globalThis.browser; + try { + for (const build of ['chrome', 'firefox']) { + const announcements = []; + const waits = []; + const runtime = { + tabs: { + async sendMessage(_tabId, message, options) { + const frameId = options?.frameId; + if (message.action === 'get_redaction_regions') { + return { viewport: { width: 100, height: 100, scrollX: 0, scrollY: 0 } }; + } + if (message.action === 'wait_for_exact_child_frame_rect') { + waits.push({ frameId, ...message.params }); + return { + found: true, + childOriginOpaque: frameId === 0 || message.params.allowOpaqueChildOrigin === true, + outerRect: { x: 10, y: 10, w: 100, h: 100, pageX: 10, pageY: 10 }, + contentRect: { x: 10, y: 10, w: 100, h: 100 }, + ownerMeta: { tag: 'iframe' }, + }; + } + if (message.action === 'announce_exact_child_frame') { + announcements.push({ frameId, ...message.params }); + return { announced: true }; + } + throw new Error(`Unexpected action: ${message.action}`); + }, + }, + }; + globalThis.chrome = build === 'chrome' + ? { ...runtime, scripting: { executeScript: async () => [] } } + : originalChrome; + globalThis.browser = build === 'firefox' + ? { ...runtime, tabs: { ...runtime.tabs, executeScript: async () => [] } } + : originalBrowser; + const { RichTextToolbarProbe } = await import(pathToFileURL( + path.join(ROOT, `src/${build}/src/agent/rich-text-toolbar-probe.js`), + ).href); + const geometry = await new RichTextToolbarProbe({}).frameGeometryToTop(77, [ + { frameId: 0, parentFrameId: -1, url: 'https://top.test/' }, + { frameId: 1, parentFrameId: 0, url: 'https://middle.test/sandboxed' }, + { frameId: 2, parentFrameId: 1, url: 'https://child.test/editor' }, + ], 2, { x: 5, y: 6, w: 20, h: 10 }); + + assert.ok(geometry?.annotationRect, `${build}: nested geometry should resolve`); + assert.equal(announcements[0]?.parentOrigin, 'https://top.test', + `${build}: ordinary parents should retain exact-origin delivery`); + assert.equal(announcements[1]?.parentOrigin, '', + `${build}: opaque sandbox parents should request wildcard delivery`); + assert.equal(waits[0]?.allowOpaqueChildOrigin, false, + `${build}: the top-level receiver should not enable inherited opacity`); + assert.equal(waits[1]?.allowOpaqueChildOrigin, true, + `${build}: an opaque receiver should accept and propagate null child origins`); + } + } finally { + if (originalChrome === undefined) delete globalThis.chrome; + else globalThis.chrome = originalChrome; + if (originalBrowser === undefined) delete globalThis.browser; + else globalThis.browser = originalBrowser; + } +}); + test('redaction collectors expose a per-frame overflow sentinel', () => { const visibleRect = { left: 10, top: 10, right: 110, bottom: 30, width: 100, height: 20 }; for (const browserName of ['chrome', 'firefox']) { @@ -1739,6 +1943,16 @@ test('ref-id action tools are state changes in both browser agents', () => { } }); +test('iframe mutations require a stable frame scope in both browser tool schemas', () => { + for (const [label, getTools] of [['chrome', getToolsForModeCh], ['firefox', getToolsForModeFx]]) { + for (const name of ['iframe_click', 'iframe_type']) { + const tool = getTools('act').find(item => item.function.name === name); + assert.ok(tool, `${label}: ${name} tool is missing`); + assert.equal(tool.function.parameters.required.includes('urlFilter'), true, `${label}: ${name} does not require urlFilter`); + } + } +}); + test('set_checked is exposed and permission-gated as a click in both browser agents', () => { for (const [label, getTools, capabilityFn, Capabilities] of [ ['chrome', getToolsForModeCh, capabilityForCh, CapabilityCh], @@ -8355,7 +8569,7 @@ test('Enter SPA route changes reset dead-scroll state and defer queued ref reuse } }); -test('accessibility-tree ref enumeration nudges at three and stops at six', () => { +test('accessibility-tree allows bounded unique ref drill-downs and still stops enumeration', () => { const d = new ConfiguredLoopDetector(); const tab = 21; const root = d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { filter: 'visible' }, { @@ -8366,10 +8580,16 @@ test('accessibility-tree ref enumeration nudges at three and stops at six', () = assert.equal(root.kind, 'none'); assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_1' }, { pageContent: 'form [ref_1]\n textbox "Subject" [ref_2]' }).kind, 'none'); assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_2' }, { pageContent: 'textbox "Subject" [ref_2]' }).kind, 'none'); - assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_3' }, { pageContent: 'generic [ref_3]' }).kind, 'nudge'); - assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_4' }, { pageContent: 'generic [ref_4]' }).kind, 'none'); - assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_5' }, { pageContent: 'generic [ref_5]' }).kind, 'none'); - assert.equal(d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_6' }, { pageContent: 'generic [ref_6]' }).kind, 'stop'); + for (let ref = 3; ref <= 10; ref++) { + assert.equal( + d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: `ref_${ref}` }, { pageContent: `generic [ref_${ref}]` }).kind, + 'none', + ); + } + assert.equal( + d._checkAccessibilityReadLoop(tab, 'get_accessibility_tree', { ref_id: 'ref_11' }, { pageContent: 'generic [ref_11]' }).kind, + 'stop', + ); }); test('accessibility-tree nextPage pagination past the read cap is not suspicious and other tools reset it', () => { @@ -10207,12 +10427,14 @@ test('loop detection classifies mutating tools from each build tool list, not a for (const name of ['read_page', 'extract_data', 'get_accessibility_tree', 'fetch_url', 'find_text', 'done']) { assert.equal(agent._isBrowserMutationTool(name), false, `${label}: ${name} must not count as a mutation`); } - // iframe_type now needs state-change screenshots for its toolbar safety - // preflight as well as its ordinary post-action evidence. - assert.equal(stateChangeTools.has('iframe_type'), true, `${label}: iframe_type must trigger state-change screenshots`); - // The remaining frame/upload tools act on the page but are not + // Iframe mutations need state-change screenshots for post-action evidence; + // iframe_type also uses them for its toolbar safety preflight. + for (const name of ['iframe_click', 'iframe_type']) { + assert.equal(stateChangeTools.has(name), true, `${label}: ${name} must trigger state-change screenshots`); + } + // The remaining upload/challenge tools act on the page but are not // auto-screenshot state changes, so the two sets must not be collapsed. - for (const name of ['iframe_click', 'upload_file', 'solve_captcha']) { + for (const name of ['upload_file', 'solve_captcha']) { assert.equal(stateChangeTools.has(name), false, `${label}: ${name} must stay out of STATE_CHANGE_TOOLS`); assert.equal(mutationTools.has(name), true, `${label}: ${name} must be a browser mutation`); } @@ -16786,6 +17008,14 @@ test('completion invariant state machine enforces post-action observation with C assert.equal(iframeFormState.iframeFormVerificationDebt, false, `${label}: matching iframe verify_form did not clear form debt`); assert.equal(invariant.completionDoneBlock(iframeFormState, 'done', { outcome: 'success' }), null); + const unscopedIframeState = invariant.recordCompletionToolResult( + invariant.createCompletionInvariantState(`${label}-unscoped-iframe-form`), + 'iframe_type', + { selector: '#field', text: 'value' }, + { success: true, dispatched: true, verified: true, frameId: 7, value: 'value' }, + ); + assert.equal(unscopedIframeState.iframeFormVerificationDebt, false, `${label}: an unscoped iframe edit created debt that verify_form cannot address`); + let multiIframeState = invariant.createCompletionInvariantState(`${label}-multi-iframe-form`); multiIframeState = invariant.recordCompletionToolResult( multiIframeState, @@ -16937,22 +17167,26 @@ test('pre-dispatch action failures opt out without weakening ambiguous iframe fa 'chrome', CompletionInvariantCh, 'iframe_type', - { selector: '::invalid' }, - await chromeAgent.executeTool(6401, 'iframe_type', { selector: '::invalid', text: 'x' }), + { urlFilter: 'example.test', selector: '::invalid' }, + await chromeAgent.executeTool(6401, 'iframe_type', { urlFilter: 'example.test', selector: '::invalid', text: 'x' }), ); + const unscopedChromeIframe = await chromeAgent.executeTool(6401, 'iframe_type', { selector: '#field', text: 'x' }); + assertNoDebt('chrome', CompletionInvariantCh, 'iframe_type', { selector: '#field', text: 'x' }, unscopedChromeIframe); + assert.match(unscopedChromeIframe.error, /urlFilter is required/); + chromeIframeTypeResponse = { success: false, dispatched: true, error: 'event handler threw after target resolution', }; - const ambiguousChromeIframe = await chromeAgent.executeTool(6401, 'iframe_type', { selector: '#field', text: 'x' }); + const ambiguousChromeIframe = await chromeAgent.executeTool(6401, 'iframe_type', { urlFilter: 'example.test', selector: '#field', text: 'x' }); assert.equal(ambiguousChromeIframe.dispatched, true, 'chrome: ambiguous iframe failure lost its dispatch marker'); assert.equal( CompletionInvariantCh.recordCompletionToolResult( CompletionInvariantCh.createCompletionInvariantState('chrome-ambiguous-iframe'), 'iframe_type', - { selector: '#field', text: 'x' }, + { urlFilter: 'example.test', selector: '#field', text: 'x' }, ambiguousChromeIframe, ).verificationDebt, true, @@ -17016,22 +17250,26 @@ test('pre-dispatch action failures opt out without weakening ambiguous iframe fa 'firefox', CompletionInvariantFx, 'iframe_type', - { selector: '::invalid' }, - await firefoxAgent.executeTool(6402, 'iframe_type', { selector: '::invalid', text: 'x' }), + { urlFilter: 'example.test', selector: '::invalid' }, + await firefoxAgent.executeTool(6402, 'iframe_type', { urlFilter: 'example.test', selector: '::invalid', text: 'x' }), ); + const unscopedFirefoxIframe = await firefoxAgent.executeTool(6402, 'iframe_type', { selector: '#field', text: 'x' }); + assertNoDebt('firefox', CompletionInvariantFx, 'iframe_type', { selector: '#field', text: 'x' }, unscopedFirefoxIframe); + assert.match(unscopedFirefoxIframe.error, /urlFilter is required/); + firefoxIframeTypeResponse = { success: false, dispatched: true, error: 'event handler threw after target resolution', }; - const ambiguousFirefoxIframe = await firefoxAgent.executeTool(6402, 'iframe_type', { selector: '#field', text: 'x' }); + const ambiguousFirefoxIframe = await firefoxAgent.executeTool(6402, 'iframe_type', { urlFilter: 'example.test', selector: '#field', text: 'x' }); assert.equal(ambiguousFirefoxIframe.dispatched, true, 'firefox: ambiguous iframe failure lost its dispatch marker'); assert.equal( CompletionInvariantFx.recordCompletionToolResult( CompletionInvariantFx.createCompletionInvariantState('firefox-ambiguous-iframe'), 'iframe_type', - { selector: '#field', text: 'x' }, + { urlFilter: 'example.test', selector: '#field', text: 'x' }, ambiguousFirefoxIframe, ).verificationDebt, true, @@ -23311,7 +23549,7 @@ test('chrome offscreen helper recreates an evicted document after ready cache is } }); -test('chrome fetch fallback clears offscreen proxy timeout after success', async () => { +test('chrome non-idempotent fetch uses one offscreen transport and clears its timeout', async () => { const previousChrome = globalThis.chrome; const previousFetch = globalThis.fetch; const previousSetTimeout = globalThis.setTimeout; @@ -23327,7 +23565,9 @@ test('chrome fetch fallback clears offscreen proxy timeout after success', async globalThis.clearTimeout = (handle) => { if (handle) handle.cleared = true; }; + let directAttempts = 0; globalThis.fetch = async () => { + directAttempts += 1; throw new TypeError('Failed to fetch'); }; globalThis.chrome = { @@ -23369,7 +23609,8 @@ test('chrome fetch fallback clears offscreen proxy timeout after success', async assert.equal(res.status, 200, 'chrome: fallback should synthesize the proxied response'); assert.equal(await res.text(), '{"ok":true}', 'chrome: fallback response body should survive proxy conversion'); - assert.equal(timers.length, 2, 'chrome: direct fetch and offscreen proxy should each install one timeout'); + assert.equal(directAttempts, 0, 'chrome: a POST must not be replayed after a direct network failure'); + assert.equal(timers.length, 1, 'chrome: the single offscreen transport should install one timeout'); assert.equal(timers.every((timer) => timer.cleared), true, 'chrome: offscreen proxy timeout should be cleared after success'); } finally { globalThis.setTimeout = previousSetTimeout; @@ -25633,6 +25874,67 @@ test('tab-chat persistence never evicts other chats when shared quota remains ex } }); +test('tab-chat quota recovery removes only a provably closed tab chat', async () => { + const originalChrome = globalThis.chrome; + const originalBrowser = globalThis.browser; + try { + for (const [label, persistence, runtimeKey] of [ + ['chrome', TabChatPersistenceCh, 'chrome'], + ['firefox', TabChatPersistenceFx, 'browser'], + ]) { + delete globalThis.chrome; + delete globalThis.browser; + globalThis[runtimeKey] = { + tabs: { + async get(tabId) { + if (tabId === 41) throw new Error('No tab with id: 41'); + if (tabId === 40) throw new Error('Tabs API temporarily unavailable'); + return { id: tabId }; + }, + }, + }; + const quota = 1200 * 1024; + const values = { unrelatedSessionState: 'keep' }; + const unknownKey = persistence.TAB_CHAT_PREFIX + '40'; + const staleKey = persistence.TAB_CHAT_PREFIX + '41'; + const openKey = persistence.TAB_CHAT_PREFIX + '42'; + const currentKey = persistence.TAB_CHAT_PREFIX + '43'; + values[unknownKey] = 'u'.repeat(750 * 1024); + values[staleKey] = 's'.repeat(700 * 1024); + values[openKey] = 'o'.repeat(300 * 1024); + const storageBytes = next => Object.entries(next) + .reduce((total, [storedKey, value]) => total + storedKey.length + String(value).length, 0); + const storageArea = { + async get() { return { ...values }; }, + async set(patch) { + const next = { ...values, ...patch }; + if (storageBytes(next) > quota) throw new Error('QUOTA_BYTES exceeded'); + Object.assign(values, patch); + }, + async remove(key) { delete values[key]; }, + }; + + const result = await persistence.persistTabChatToSession( + storageArea, + currentKey, + `